syntaur 0.2.0 → 0.3.3

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 (99) 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 +2185 -609
  53. package/dist/dashboard/server.js.map +1 -1
  54. package/dist/index.js +2596 -959
  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/.claude-plugin/plugin.json +5 -1
  70. package/platforms/claude-code/agents/syntaur-expert.md +46 -15
  71. package/platforms/claude-code/commands/track-session/track-session.md +43 -18
  72. package/platforms/claude-code/hooks/hooks.json +11 -0
  73. package/platforms/claude-code/hooks/session-cleanup.sh +13 -23
  74. package/platforms/claude-code/hooks/session-start.sh +80 -0
  75. package/platforms/claude-code/hooks/statusline.sh +110 -0
  76. package/platforms/claude-code/references/file-ownership.md +15 -3
  77. package/platforms/claude-code/references/protocol-summary.md +19 -5
  78. package/platforms/claude-code/skills/complete-assignment/SKILL.md +14 -0
  79. package/platforms/claude-code/skills/create-assignment/SKILL.md +12 -10
  80. package/platforms/claude-code/skills/grab-assignment/SKILL.md +30 -15
  81. package/platforms/claude-code/skills/plan-assignment/SKILL.md +16 -8
  82. package/platforms/claude-code/skills/syntaur-protocol/SKILL.md +21 -11
  83. package/platforms/codex/.codex-plugin/plugin.json +1 -1
  84. package/platforms/codex/agents/syntaur-operator.md +39 -25
  85. package/platforms/codex/references/file-ownership.md +14 -3
  86. package/platforms/codex/references/protocol-summary.md +19 -5
  87. package/platforms/codex/scripts/resolve-session.sh +49 -0
  88. package/platforms/codex/skills/complete-assignment/SKILL.md +1 -0
  89. package/platforms/codex/skills/create-assignment/SKILL.md +13 -8
  90. package/platforms/codex/skills/grab-assignment/SKILL.md +7 -5
  91. package/platforms/codex/skills/plan-assignment/SKILL.md +8 -4
  92. package/platforms/codex/skills/syntaur-protocol/SKILL.md +26 -13
  93. package/dashboard/dist/assets/channel-DdltvFFH.js +0 -1
  94. package/dashboard/dist/assets/classDiagram-VBA2DB6C-BHqdFE-8.js +0 -1
  95. package/dashboard/dist/assets/classDiagram-v2-RAHNMMFH-BHqdFE-8.js +0 -1
  96. package/dashboard/dist/assets/clone-CBJOOeOm.js +0 -1
  97. package/dashboard/dist/assets/index-CoVCLSh2.css +0 -1
  98. package/dashboard/dist/assets/index-yyAIuzrP.js +0 -471
  99. 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";
@@ -438,9 +484,144 @@ var init_config = __esm({
438
484
  }
439
485
  });
440
486
 
487
+ // src/utils/fs-migration.ts
488
+ import { readdir, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "fs/promises";
489
+ import { resolve as resolve3 } from "path";
490
+ async function migrateLegacyProjectFiles(projectsDir) {
491
+ const result = {
492
+ renamedProjectFiles: [],
493
+ legacyExtras: []
494
+ };
495
+ if (!await fileExists(projectsDir)) return result;
496
+ let entries;
497
+ try {
498
+ entries = await readdir(projectsDir, { withFileTypes: true });
499
+ } catch {
500
+ return result;
501
+ }
502
+ for (const entry of entries) {
503
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
504
+ const projectDir = resolve3(projectsDir, entry.name);
505
+ const legacy = resolve3(projectDir, "mission.md");
506
+ const target = resolve3(projectDir, "project.md");
507
+ try {
508
+ if (await fileExists(legacy) && !await fileExists(target)) {
509
+ await rename2(legacy, target);
510
+ result.renamedProjectFiles.push(`${entry.name}/mission.md`);
511
+ }
512
+ } catch {
513
+ continue;
514
+ }
515
+ for (const stale of ["agent.md", "claude.md"]) {
516
+ try {
517
+ if (await fileExists(resolve3(projectDir, stale))) {
518
+ result.legacyExtras.push(`${entry.name}/${stale}`);
519
+ }
520
+ } catch {
521
+ }
522
+ }
523
+ }
524
+ return result;
525
+ }
526
+ async function migrateLegacyConfig(configPath) {
527
+ const result = {
528
+ renamedField: false,
529
+ renamedDir: false,
530
+ resolvedProjectsDir: null
531
+ };
532
+ if (!await fileExists(configPath)) return result;
533
+ let content;
534
+ try {
535
+ content = await readFile2(configPath, "utf-8");
536
+ } catch {
537
+ return result;
538
+ }
539
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?/);
540
+ if (!fmMatch) return result;
541
+ const fmBlock = fmMatch[1];
542
+ const afterFm = content.slice(fmMatch[0].length);
543
+ const missionLineRe = /^(\s*)defaultMissionDir\s*:\s*(.*)$/m;
544
+ const missionLineMatch = fmBlock.match(missionLineRe);
545
+ const hasProjectLine = /^\s*defaultProjectDir\s*:/m.test(fmBlock);
546
+ let newFmBlock = fmBlock;
547
+ let missionValue = null;
548
+ if (missionLineMatch) {
549
+ missionValue = missionLineMatch[2].trim();
550
+ if (!hasProjectLine) {
551
+ newFmBlock = fmBlock.replace(
552
+ missionLineRe,
553
+ `$1defaultProjectDir: ${missionValue}`
554
+ );
555
+ result.renamedField = true;
556
+ } else {
557
+ newFmBlock = fmBlock.replace(missionLineRe, "").replace(/\n{2,}/g, "\n");
558
+ result.renamedField = true;
559
+ }
560
+ }
561
+ const projectLineRe = /^\s*defaultProjectDir\s*:\s*(.*)$/m;
562
+ const projectLineMatch = newFmBlock.match(projectLineRe);
563
+ const projectsDirRaw = projectLineMatch ? projectLineMatch[1].trim().replace(/^['"]|['"]$/g, "") : missionValue;
564
+ const expand = (p) => p.startsWith("~") ? resolve3(process.env.HOME ?? "/", p.slice(p.startsWith("~/") ? 2 : 1)) : p;
565
+ let resolvedProjectsDir = projectsDirRaw ? expand(projectsDirRaw) : null;
566
+ if (resolvedProjectsDir && resolvedProjectsDir.endsWith("/missions")) {
567
+ const siblingProjectsDir = resolvedProjectsDir.replace(/\/missions$/, "/projects");
568
+ if (await fileExists(resolvedProjectsDir) && !await fileExists(siblingProjectsDir)) {
569
+ try {
570
+ await rename2(resolvedProjectsDir, siblingProjectsDir);
571
+ const newValue = projectsDirRaw.endsWith("/missions") ? projectsDirRaw.replace(/\/missions$/, "/projects") : siblingProjectsDir;
572
+ newFmBlock = newFmBlock.replace(
573
+ projectLineRe,
574
+ `defaultProjectDir: ${newValue}`
575
+ );
576
+ resolvedProjectsDir = siblingProjectsDir;
577
+ result.renamedDir = true;
578
+ } catch {
579
+ }
580
+ }
581
+ }
582
+ result.resolvedProjectsDir = resolvedProjectsDir;
583
+ if (result.renamedField || result.renamedDir) {
584
+ const newContent = `---
585
+ ${newFmBlock.replace(/\n+$/, "")}
586
+ ---
587
+ ${afterFm.startsWith("\n") ? afterFm.slice(1) : afterFm}`;
588
+ try {
589
+ await writeFile2(configPath, newContent, "utf-8");
590
+ } catch {
591
+ result.renamedField = false;
592
+ result.renamedDir = false;
593
+ }
594
+ }
595
+ return result;
596
+ }
597
+ function summarizeMigration(project, config) {
598
+ const parts = [];
599
+ if (project.renamedProjectFiles.length > 0) {
600
+ const firstThree = project.renamedProjectFiles.map((p) => p.split("/")[0]).slice(0, 3).join(", ");
601
+ const more = project.renamedProjectFiles.length > 3 ? ` and ${project.renamedProjectFiles.length - 3} more` : "";
602
+ parts.push(
603
+ `renamed mission.md \u2192 project.md in ${project.renamedProjectFiles.length} project${project.renamedProjectFiles.length === 1 ? "" : "s"} (${firstThree}${more})`
604
+ );
605
+ }
606
+ if (config?.renamedField) parts.push("updated config defaultMissionDir \u2192 defaultProjectDir");
607
+ if (config?.renamedDir) parts.push("renamed projects directory on disk");
608
+ if (project.legacyExtras.length > 0) {
609
+ parts.push(
610
+ `${project.legacyExtras.length} legacy agent.md / claude.md file${project.legacyExtras.length === 1 ? "" : "s"} left in place (no longer read)`
611
+ );
612
+ }
613
+ return parts.length ? `[syntaur] legacy migration: ${parts.join("; ")}` : "";
614
+ }
615
+ var init_fs_migration = __esm({
616
+ "src/utils/fs-migration.ts"() {
617
+ "use strict";
618
+ init_fs();
619
+ }
620
+ });
621
+
441
622
  // src/utils/config.ts
442
- import { readFile as readFile2 } from "fs/promises";
443
- import { resolve as resolve3, isAbsolute } from "path";
623
+ import { readFile as readFile3 } from "fs/promises";
624
+ import { resolve as resolve4, isAbsolute } from "path";
444
625
  function parseFrontmatter(content) {
445
626
  const match = content.match(/^---\n([\s\S]*?)\n---/);
446
627
  if (!match) return {};
@@ -627,10 +808,10 @@ function parseOptionalAbsolutePath(value, fieldName) {
627
808
  );
628
809
  return null;
629
810
  }
630
- return resolve3(expanded);
811
+ return resolve4(expanded);
631
812
  }
632
813
  async function writeStatusConfig(statuses) {
633
- const configPath = resolve3(syntaurRoot(), "config.md");
814
+ const configPath = resolve4(syntaurRoot(), "config.md");
634
815
  const statusBlock = serializeStatusConfig(statuses);
635
816
  if (!await fileExists(configPath)) {
636
817
  const content = `---
@@ -642,7 +823,7 @@ ${statusBlock}
642
823
  await writeFileForce(configPath, content);
643
824
  return;
644
825
  }
645
- const existing = await readFile2(configPath, "utf-8");
826
+ const existing = await readFile3(configPath, "utf-8");
646
827
  const fmMatch = existing.match(/^(---\n)([\s\S]*?)\n(---)/);
647
828
  if (!fmMatch) {
648
829
  const content = `---
@@ -684,9 +865,9 @@ ${statusBlock}
684
865
  await writeFileForce(configPath, newContent);
685
866
  }
686
867
  async function deleteStatusConfig() {
687
- const configPath = resolve3(syntaurRoot(), "config.md");
868
+ const configPath = resolve4(syntaurRoot(), "config.md");
688
869
  if (!await fileExists(configPath)) return;
689
- const existing = await readFile2(configPath, "utf-8");
870
+ const existing = await readFile3(configPath, "utf-8");
690
871
  const fmMatch = existing.match(/^(---\n)([\s\S]*?)\n(---)/);
691
872
  if (!fmMatch) return;
692
873
  const fmBlock = fmMatch[2];
@@ -698,7 +879,7 @@ ${cleanedFm}
698
879
  await writeFileForce(configPath, newContent);
699
880
  }
700
881
  async function updateBackupConfig(backup) {
701
- const configPath = resolve3(syntaurRoot(), "config.md");
882
+ const configPath = resolve4(syntaurRoot(), "config.md");
702
883
  const current = (await readConfig()).backup;
703
884
  const nextBackup = {
704
885
  repo: current?.repo ?? null,
@@ -708,7 +889,7 @@ async function updateBackupConfig(backup) {
708
889
  ...backup
709
890
  };
710
891
  const backupBlock = serializeBackupConfig(nextBackup);
711
- const existing = await fileExists(configPath) ? await readFile2(configPath, "utf-8") : renderConfig({ defaultProjectDir: defaultProjectDir() });
892
+ const existing = await fileExists(configPath) ? await readFile3(configPath, "utf-8") : renderConfig({ defaultProjectDir: defaultProjectDir() });
712
893
  const fmMatch = existing.match(/^(---\n)([\s\S]*?)\n(---)/);
713
894
  if (!fmMatch) {
714
895
  const content = `---
@@ -732,11 +913,15 @@ ${normalizedFm}
732
913
  await writeFileForce(configPath, newContent);
733
914
  }
734
915
  async function readConfig() {
735
- const configPath = resolve3(syntaurRoot(), "config.md");
916
+ const configPath = resolve4(syntaurRoot(), "config.md");
736
917
  if (!await fileExists(configPath)) {
737
918
  return { ...DEFAULT_CONFIG };
738
919
  }
739
- const content = await readFile2(configPath, "utf-8");
920
+ if (!migratedConfigPaths.has(configPath)) {
921
+ migratedConfigPaths.add(configPath);
922
+ await migrateLegacyConfig(configPath);
923
+ }
924
+ const content = await readFile3(configPath, "utf-8");
740
925
  const fm = parseFrontmatter(content);
741
926
  if (Object.keys(fm).length === 0) {
742
927
  console.warn("Warning: ~/.syntaur/config.md has malformed frontmatter, using defaults");
@@ -783,13 +968,14 @@ async function readConfig() {
783
968
  types: null
784
969
  };
785
970
  }
786
- var DEFAULT_CONFIG;
971
+ var DEFAULT_CONFIG, migratedConfigPaths;
787
972
  var init_config2 = __esm({
788
973
  "src/utils/config.ts"() {
789
974
  "use strict";
790
975
  init_paths();
791
976
  init_fs();
792
977
  init_config();
978
+ init_fs_migration();
793
979
  DEFAULT_CONFIG = {
794
980
  version: "2.0",
795
981
  defaultProjectDir: defaultProjectDir(),
@@ -809,6 +995,7 @@ var init_config2 = __esm({
809
995
  statuses: null,
810
996
  types: null
811
997
  };
998
+ migratedConfigPaths = /* @__PURE__ */ new Set();
812
999
  }
813
1000
  });
814
1001
 
@@ -862,9 +1049,10 @@ function parseListField(frontmatter, fieldName) {
862
1049
  }
863
1050
  function parseProject(fileContent) {
864
1051
  const [fm, body] = extractFrontmatter2(fileContent);
1052
+ const slug = getField(fm, "slug") ?? getField(fm, "mission") ?? "";
865
1053
  return {
866
1054
  id: getField(fm, "id") ?? "",
867
- slug: getField(fm, "slug") ?? "",
1055
+ slug,
868
1056
  title: getField(fm, "title") ?? "",
869
1057
  archived: getField(fm, "archived") === "true",
870
1058
  archivedAt: getField(fm, "archivedAt"),
@@ -996,6 +1184,58 @@ function parseDecisionRecord(fileContent) {
996
1184
  body
997
1185
  };
998
1186
  }
1187
+ function parseComments(fileContent) {
1188
+ const [fm, body] = extractFrontmatter2(fileContent);
1189
+ const entries = [];
1190
+ const sections = body.split(/^## /m).slice(1);
1191
+ for (const section of sections) {
1192
+ const newlineIdx = section.indexOf("\n");
1193
+ if (newlineIdx === -1) continue;
1194
+ const id = section.slice(0, newlineIdx).trim();
1195
+ const rest = section.slice(newlineIdx + 1);
1196
+ const headerMatch = rest.match(
1197
+ /^\s*\*\*Recorded:\*\*\s*(.*)\n\*\*Author:\*\*\s*(.*)\n\*\*Type:\*\*\s*(question|note|feedback)(?:\n\*\*Reply to:\*\*\s*(.*))?(?:\n\*\*Resolved:\*\*\s*(true|false))?\n+([\s\S]*)$/
1198
+ );
1199
+ if (!headerMatch) continue;
1200
+ const [, timestamp, author, type, replyTo, resolvedStr, entryBody] = headerMatch;
1201
+ const entry = {
1202
+ id,
1203
+ timestamp: timestamp.trim(),
1204
+ author: author.trim(),
1205
+ type,
1206
+ body: entryBody.trim()
1207
+ };
1208
+ if (replyTo) entry.replyTo = replyTo.trim();
1209
+ if (resolvedStr) entry.resolved = resolvedStr === "true";
1210
+ entries.push(entry);
1211
+ }
1212
+ return {
1213
+ assignment: getField(fm, "assignment") ?? "",
1214
+ entryCount: parseInt(getField(fm, "entryCount") ?? "0", 10),
1215
+ updated: getField(fm, "updated") ?? "",
1216
+ entries,
1217
+ body
1218
+ };
1219
+ }
1220
+ function parseProgress(fileContent) {
1221
+ const [fm, body] = extractFrontmatter2(fileContent);
1222
+ const entries = [];
1223
+ const sections = body.split(/^## /m).slice(1);
1224
+ for (const section of sections) {
1225
+ const newlineIdx = section.indexOf("\n");
1226
+ if (newlineIdx === -1) continue;
1227
+ const timestamp = section.slice(0, newlineIdx).trim();
1228
+ const entryBody = section.slice(newlineIdx + 1).trim();
1229
+ entries.push({ timestamp, body: entryBody });
1230
+ }
1231
+ return {
1232
+ assignment: getField(fm, "assignment") ?? "",
1233
+ entryCount: parseInt(getField(fm, "entryCount") ?? "0", 10),
1234
+ updated: getField(fm, "updated") ?? "",
1235
+ entries,
1236
+ body
1237
+ };
1238
+ }
999
1239
  function parseResource(fileContent) {
1000
1240
  const [fm, body] = extractFrontmatter2(fileContent);
1001
1241
  return {
@@ -1044,6 +1284,74 @@ var init_parser = __esm({
1044
1284
  }
1045
1285
  });
1046
1286
 
1287
+ // src/utils/assignment-resolver.ts
1288
+ import { resolve as resolve5 } from "path";
1289
+ import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
1290
+ async function resolveAssignmentById(projectsDir, assignmentsDir, id) {
1291
+ let standaloneMatch = null;
1292
+ let projectMatch = null;
1293
+ const standaloneDir = resolve5(assignmentsDir, id);
1294
+ const standalonePath = resolve5(standaloneDir, "assignment.md");
1295
+ if (await fileExists(standalonePath)) {
1296
+ standaloneMatch = {
1297
+ assignmentDir: standaloneDir,
1298
+ projectSlug: null,
1299
+ assignmentSlug: id,
1300
+ id,
1301
+ standalone: true
1302
+ };
1303
+ }
1304
+ if (await fileExists(projectsDir)) {
1305
+ try {
1306
+ const projects = await readdir2(projectsDir, { withFileTypes: true });
1307
+ for (const p of projects) {
1308
+ if (!p.isDirectory()) continue;
1309
+ if (p.name.startsWith(".") || p.name.startsWith("_")) continue;
1310
+ const assignmentsPath = resolve5(projectsDir, p.name, "assignments");
1311
+ if (!await fileExists(assignmentsPath)) continue;
1312
+ const entries = await readdir2(assignmentsPath, { withFileTypes: true });
1313
+ for (const a of entries) {
1314
+ if (!a.isDirectory()) continue;
1315
+ const aPath = resolve5(assignmentsPath, a.name, "assignment.md");
1316
+ if (!await fileExists(aPath)) continue;
1317
+ try {
1318
+ const content = await readFile4(aPath, "utf-8");
1319
+ const [fm] = extractFrontmatter2(content);
1320
+ const fileId = getField(fm, "id");
1321
+ if (fileId === id) {
1322
+ projectMatch = {
1323
+ assignmentDir: resolve5(assignmentsPath, a.name),
1324
+ projectSlug: p.name,
1325
+ assignmentSlug: a.name,
1326
+ id,
1327
+ standalone: false
1328
+ };
1329
+ break;
1330
+ }
1331
+ } catch {
1332
+ }
1333
+ }
1334
+ if (projectMatch) break;
1335
+ }
1336
+ } catch {
1337
+ }
1338
+ }
1339
+ if (standaloneMatch && projectMatch) {
1340
+ console.warn(
1341
+ `Duplicate assignment ID ${id} found in both standalone and project-nested locations; using standalone`
1342
+ );
1343
+ return standaloneMatch;
1344
+ }
1345
+ return standaloneMatch ?? projectMatch ?? null;
1346
+ }
1347
+ var init_assignment_resolver = __esm({
1348
+ "src/utils/assignment-resolver.ts"() {
1349
+ "use strict";
1350
+ init_fs();
1351
+ init_parser();
1352
+ }
1353
+ });
1354
+
1047
1355
  // src/dashboard/help.ts
1048
1356
  async function buildStatusGuide() {
1049
1357
  const config = await getStatusConfig();
@@ -1376,8 +1684,8 @@ var init_help = __esm({
1376
1684
  // --- Session & server tracking (index 17) ---
1377
1685
  {
1378
1686
  command: "syntaur track-session",
1379
- description: "Register an agent session, optionally linked to a project and assignment.",
1380
- example: "syntaur track-session --agent claude --project ui-overhaul --assignment implement-overview"
1687
+ description: "Register an agent session. Requires --session-id from the agent runtime (real, not generated). Pass --transcript-path for the rollout/transcript file. --project and --assignment are optional.",
1688
+ example: "syntaur track-session --agent claude --session-id <real-id> --transcript-path <path> --project ui-overhaul --assignment implement-overview"
1381
1689
  },
1382
1690
  // --- Browsing & playbooks (indices 18-20) ---
1383
1691
  {
@@ -1460,8 +1768,8 @@ var init_help = __esm({
1460
1768
  });
1461
1769
 
1462
1770
  // src/dashboard/servers.ts
1463
- import { readdir, readFile as readFile3, unlink } from "fs/promises";
1464
- import { resolve as resolve4 } from "path";
1771
+ import { readdir as readdir3, readFile as readFile5, unlink } from "fs/promises";
1772
+ import { resolve as resolve6 } from "path";
1465
1773
  function sanitizeSessionName(name) {
1466
1774
  return name.replace(/[^a-zA-Z0-9_-]/g, "-");
1467
1775
  }
@@ -1509,18 +1817,18 @@ async function registerSession(dir, rawName) {
1509
1817
  lastRefreshed: now,
1510
1818
  overrides: {}
1511
1819
  });
1512
- await writeFileForce(resolve4(dir, `${name}.md`), content);
1820
+ await writeFileForce(resolve6(dir, `${name}.md`), content);
1513
1821
  return name;
1514
1822
  }
1515
1823
  async function listSessionFiles(dir) {
1516
1824
  if (!await fileExists(dir)) return [];
1517
- const entries = await readdir(dir);
1825
+ const entries = await readdir3(dir);
1518
1826
  return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
1519
1827
  }
1520
1828
  async function readSessionFile(dir, name) {
1521
- const filePath = resolve4(dir, `${sanitizeSessionName(name)}.md`);
1829
+ const filePath = resolve6(dir, `${sanitizeSessionName(name)}.md`);
1522
1830
  if (!await fileExists(filePath)) return null;
1523
- const raw = await readFile3(filePath, "utf-8");
1831
+ const raw = await readFile5(filePath, "utf-8");
1524
1832
  const [frontmatter] = extractFrontmatter2(raw);
1525
1833
  if (!frontmatter) return null;
1526
1834
  const session = getField(frontmatter, "session") ?? name;
@@ -1560,7 +1868,7 @@ async function readSessionFile(dir, name) {
1560
1868
  };
1561
1869
  }
1562
1870
  async function removeSession(dir, name) {
1563
- const filePath = resolve4(dir, `${sanitizeSessionName(name)}.md`);
1871
+ const filePath = resolve6(dir, `${sanitizeSessionName(name)}.md`);
1564
1872
  if (await fileExists(filePath)) {
1565
1873
  await unlink(filePath);
1566
1874
  }
@@ -1569,7 +1877,7 @@ async function updateLastRefreshed(dir, name) {
1569
1877
  const data = await readSessionFile(dir, name);
1570
1878
  if (!data) return;
1571
1879
  const content = buildSessionContent({ ...data, lastRefreshed: nowTimestamp2() });
1572
- await writeFileForce(resolve4(dir, `${sanitizeSessionName(name)}.md`), content);
1880
+ await writeFileForce(resolve6(dir, `${sanitizeSessionName(name)}.md`), content);
1573
1881
  }
1574
1882
  async function setOverride(dir, sessionName, windowIndex, paneIndex, assignment) {
1575
1883
  const data = await readSessionFile(dir, sessionName);
@@ -1581,7 +1889,7 @@ async function setOverride(dir, sessionName, windowIndex, paneIndex, assignment)
1581
1889
  delete data.overrides[key];
1582
1890
  }
1583
1891
  const content = buildSessionContent({ ...data });
1584
- await writeFileForce(resolve4(dir, `${sanitizeSessionName(sessionName)}.md`), content);
1892
+ await writeFileForce(resolve6(dir, `${sanitizeSessionName(sessionName)}.md`), content);
1585
1893
  }
1586
1894
  async function registerAutoSession(dir, rawName, opts) {
1587
1895
  const name = sanitizeSessionName(rawName);
@@ -1598,7 +1906,7 @@ async function registerAutoSession(dir, rawName, opts) {
1598
1906
  ports: opts.ports,
1599
1907
  cwd: opts.cwd
1600
1908
  });
1601
- await writeFileForce(resolve4(dir, `${name}.md`), content);
1909
+ await writeFileForce(resolve6(dir, `${name}.md`), content);
1602
1910
  return name;
1603
1911
  }
1604
1912
  var init_servers = __esm({
@@ -1630,8 +1938,8 @@ __export(scanner_exports, {
1630
1938
  });
1631
1939
  import { execFile } from "child_process";
1632
1940
  import { promisify } from "util";
1633
- import { resolve as resolve5 } from "path";
1634
- import { realpath, readdir as readdir2, readFile as readFile4 } from "fs/promises";
1941
+ import { resolve as resolve7 } from "path";
1942
+ import { realpath, readdir as readdir4, readFile as readFile6 } from "fs/promises";
1635
1943
  function clearScanCache() {
1636
1944
  cache = null;
1637
1945
  }
@@ -1726,8 +2034,8 @@ async function getGitInfo(cwd) {
1726
2034
  let isWorktree = false;
1727
2035
  if (commonDir && gitDir && commonDir !== gitDir) {
1728
2036
  try {
1729
- const resolvedCommon = await realpath(resolve5(cwd, commonDir));
1730
- const resolvedGit = await realpath(resolve5(cwd, gitDir));
2037
+ const resolvedCommon = await realpath(resolve7(cwd, commonDir));
2038
+ const resolvedGit = await realpath(resolve7(cwd, gitDir));
1731
2039
  isWorktree = resolvedCommon !== resolvedGit;
1732
2040
  } catch {
1733
2041
  isWorktree = false;
@@ -1735,22 +2043,22 @@ async function getGitInfo(cwd) {
1735
2043
  }
1736
2044
  return { branch: branch || null, worktree: isWorktree };
1737
2045
  }
1738
- async function loadWorkspaceRecords(projectsDir) {
2046
+ async function loadWorkspaceRecords(projectsDir, assignmentsDir) {
1739
2047
  const records = [];
1740
2048
  try {
1741
2049
  const projects = await listProjects(projectsDir);
1742
2050
  for (const project of projects) {
1743
- const assignmentsDir = resolve5(projectsDir, project.slug, "assignments");
2051
+ const projectAssignmentsDir = resolve7(projectsDir, project.slug, "assignments");
1744
2052
  let slugs;
1745
2053
  try {
1746
- slugs = await readdir2(assignmentsDir);
2054
+ slugs = await readdir4(projectAssignmentsDir);
1747
2055
  } catch {
1748
2056
  continue;
1749
2057
  }
1750
2058
  for (const aslug of slugs) {
1751
- const aFile = resolve5(assignmentsDir, aslug, "assignment.md");
2059
+ const aFile = resolve7(projectAssignmentsDir, aslug, "assignment.md");
1752
2060
  try {
1753
- const raw = await readFile4(aFile, "utf-8");
2061
+ const raw = await readFile6(aFile, "utf-8");
1754
2062
  const [fm] = extractFrontmatter2(raw);
1755
2063
  if (!fm) continue;
1756
2064
  records.push({
@@ -1767,6 +2075,30 @@ async function loadWorkspaceRecords(projectsDir) {
1767
2075
  }
1768
2076
  } catch {
1769
2077
  }
2078
+ if (assignmentsDir) {
2079
+ try {
2080
+ const entries = await readdir4(assignmentsDir);
2081
+ for (const id of entries) {
2082
+ if (id.startsWith(".") || id.startsWith("_")) continue;
2083
+ const aFile = resolve7(assignmentsDir, id, "assignment.md");
2084
+ try {
2085
+ const raw = await readFile6(aFile, "utf-8");
2086
+ const [fm] = extractFrontmatter2(raw);
2087
+ if (!fm) continue;
2088
+ records.push({
2089
+ projectSlug: null,
2090
+ assignmentSlug: id,
2091
+ assignmentTitle: getField(fm, "title") ?? id,
2092
+ worktreePath: getNestedField(fm, "workspace", "worktreePath") ?? null,
2093
+ branch: getNestedField(fm, "workspace", "branch") ?? null
2094
+ });
2095
+ } catch {
2096
+ continue;
2097
+ }
2098
+ }
2099
+ } catch {
2100
+ }
2101
+ }
1770
2102
  return records;
1771
2103
  }
1772
2104
  async function resolveAndNormalize(p) {
@@ -1959,7 +2291,7 @@ async function scanAllSessions(serversDir2, projectsDir, options) {
1959
2291
  const tmuxAvailable = await checkTmuxAvailable();
1960
2292
  const names = await listSessionFiles(serversDir2);
1961
2293
  const lsofOutput = await getLsofOutput();
1962
- const workspaceRecords = await loadWorkspaceRecords(projectsDir);
2294
+ const workspaceRecords = await loadWorkspaceRecords(projectsDir, options?.assignmentsDir);
1963
2295
  const sessions = [];
1964
2296
  for (const name of names) {
1965
2297
  const data = await readSessionFile(serversDir2, name);
@@ -1974,11 +2306,11 @@ async function scanAllSessions(serversDir2, projectsDir, options) {
1974
2306
  cache = { data: result, expiry: Date.now() + CACHE_TTL_MS };
1975
2307
  return result;
1976
2308
  }
1977
- async function scanSingleSession(serversDir2, projectsDir, name) {
2309
+ async function scanSingleSession(serversDir2, projectsDir, name, options) {
1978
2310
  const data = await readSessionFile(serversDir2, name);
1979
2311
  if (!data) return null;
1980
2312
  const lsofOutput = await getLsofOutput();
1981
- const workspaceRecords = await loadWorkspaceRecords(projectsDir);
2313
+ const workspaceRecords = await loadWorkspaceRecords(projectsDir, options?.assignmentsDir);
1982
2314
  if (data.kind === "process") {
1983
2315
  return scanProcessSession(data, lsofOutput, workspaceRecords);
1984
2316
  }
@@ -1998,8 +2330,28 @@ var init_scanner = __esm({
1998
2330
  });
1999
2331
 
2000
2332
  // 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";
2333
+ import { readdir as readdir5, readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
2334
+ import { resolve as resolve8, dirname as dirname2 } from "path";
2335
+ async function listStandaloneRecords(assignmentsDir) {
2336
+ if (!assignmentsDir) return [];
2337
+ if (!await fileExists(assignmentsDir)) return [];
2338
+ const entries = await readdir5(assignmentsDir, { withFileTypes: true });
2339
+ const records = [];
2340
+ for (const entry of entries) {
2341
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
2342
+ const assignmentDir = resolve8(assignmentsDir, entry.name);
2343
+ const assignmentMdPath = resolve8(assignmentDir, "assignment.md");
2344
+ if (!await fileExists(assignmentMdPath)) continue;
2345
+ try {
2346
+ const content = await readFile7(assignmentMdPath, "utf-8");
2347
+ const record = parseAssignmentFull(content);
2348
+ records.push({ assignmentDir, id: entry.name, record });
2349
+ } catch {
2350
+ }
2351
+ }
2352
+ records.sort((left, right) => compareTimestamps(right.record.updated, left.record.updated));
2353
+ return records;
2354
+ }
2003
2355
  function toTitleCase(s) {
2004
2356
  return s.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
2005
2357
  }
@@ -2060,9 +2412,9 @@ async function listProjects(projectsDir) {
2060
2412
  return projectRecords.map((record) => record.summary);
2061
2413
  }
2062
2414
  async function readWorkspaceRegistry(projectsDir) {
2063
- const registryPath = resolve6(dirname2(projectsDir), "workspaces.json");
2415
+ const registryPath = resolve8(dirname2(projectsDir), "workspaces.json");
2064
2416
  try {
2065
- const raw = await readFile5(registryPath, "utf-8");
2417
+ const raw = await readFile7(registryPath, "utf-8");
2066
2418
  const parsed = JSON.parse(raw);
2067
2419
  return Array.isArray(parsed) ? parsed.filter((w) => typeof w === "string") : [];
2068
2420
  } catch {
@@ -2070,8 +2422,8 @@ async function readWorkspaceRegistry(projectsDir) {
2070
2422
  }
2071
2423
  }
2072
2424
  async function writeWorkspaceRegistry(projectsDir, workspaces) {
2073
- const registryPath = resolve6(dirname2(projectsDir), "workspaces.json");
2074
- await writeFile2(registryPath, JSON.stringify(workspaces, null, 2) + "\n", "utf-8");
2425
+ const registryPath = resolve8(dirname2(projectsDir), "workspaces.json");
2426
+ await writeFile3(registryPath, JSON.stringify(workspaces, null, 2) + "\n", "utf-8");
2075
2427
  }
2076
2428
  async function listWorkspaces(projectsDir) {
2077
2429
  const [projectRecords, registered] = await Promise.all([
@@ -2103,15 +2455,16 @@ async function deleteWorkspace(projectsDir, name) {
2103
2455
  const filtered = registered.filter((w) => w !== name);
2104
2456
  await writeWorkspaceRegistry(projectsDir, filtered);
2105
2457
  }
2106
- async function getOverview(projectsDir, serversDir2) {
2458
+ async function getOverview(projectsDir, serversDir2, assignmentsDir) {
2107
2459
  const projectRecords = await listProjectRecords(projectsDir);
2108
- const attention = buildAttentionItems(projectRecords);
2109
- const recentActivity = buildRecentActivity(projectRecords);
2460
+ const standaloneRecords = await listStandaloneRecords(assignmentsDir);
2461
+ const attention = buildAttentionItems(projectRecords, standaloneRecords);
2462
+ const recentActivity = buildRecentActivity(projectRecords, standaloneRecords);
2110
2463
  let serverStats;
2111
2464
  if (serversDir2) {
2112
2465
  try {
2113
2466
  const { scanAllSessions: scanAllSessions2 } = await Promise.resolve().then(() => (init_scanner(), scanner_exports));
2114
- const servers = await scanAllSessions2(serversDir2, projectsDir);
2467
+ const servers = await scanAllSessions2(serversDir2, projectsDir, { assignmentsDir });
2115
2468
  if (servers.tmuxAvailable) {
2116
2469
  const alive = servers.sessions.filter((s) => s.alive).length;
2117
2470
  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 +2480,7 @@ async function getOverview(projectsDir, serversDir2) {
2127
2480
  }
2128
2481
  return {
2129
2482
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2130
- firstRun: projectRecords.length === 0,
2483
+ firstRun: projectRecords.length === 0 && standaloneRecords.length === 0,
2131
2484
  stats: {
2132
2485
  activeProjects: projectRecords.filter((record) => record.summary.status === "active").length,
2133
2486
  inProgressAssignments: projectRecords.reduce(
@@ -2149,7 +2502,7 @@ async function getOverview(projectsDir, serversDir2) {
2149
2502
  staleAssignments: projectRecords.reduce(
2150
2503
  (total, record) => total + record.assignments.filter((assignment) => isStale(assignment.updated)).length,
2151
2504
  0
2152
- )
2505
+ ) + standaloneRecords.filter((sr) => isStale(sr.record.updated)).length
2153
2506
  },
2154
2507
  attention: attention.slice(0, OVERVIEW_ATTENTION_LIMIT),
2155
2508
  recentProjects: projectRecords.map((record) => record.summary).sort((left, right) => compareTimestamps(right.updated, left.updated)).slice(0, RECENT_PROJECTS_LIMIT),
@@ -2157,13 +2510,14 @@ async function getOverview(projectsDir, serversDir2) {
2157
2510
  serverStats
2158
2511
  };
2159
2512
  }
2160
- async function getAttention(projectsDir, serversDir2) {
2513
+ async function getAttention(projectsDir, serversDir2, assignmentsDir) {
2161
2514
  const projectRecords = await listProjectRecords(projectsDir);
2162
- const items = buildAttentionItems(projectRecords);
2515
+ const standaloneRecords = await listStandaloneRecords(assignmentsDir);
2516
+ const items = buildAttentionItems(projectRecords, standaloneRecords);
2163
2517
  if (serversDir2) {
2164
2518
  try {
2165
2519
  const { scanAllSessions: scanAllSessions2 } = await Promise.resolve().then(() => (init_scanner(), scanner_exports));
2166
- const servers = await scanAllSessions2(serversDir2, projectsDir);
2520
+ const servers = await scanAllSessions2(serversDir2, projectsDir, { assignmentsDir });
2167
2521
  for (const session of servers.sessions) {
2168
2522
  if (!session.alive) {
2169
2523
  items.push({
@@ -2207,9 +2561,9 @@ async function getAttention(projectsDir, serversDir2) {
2207
2561
  items: pagedItems
2208
2562
  };
2209
2563
  }
2210
- async function listAssignmentsBoard(projectsDir) {
2564
+ async function listAssignmentsBoard(projectsDir, assignmentsDir) {
2211
2565
  const projectRecords = await listProjectRecords(projectsDir);
2212
- const assignments = await Promise.all(
2566
+ const projectItems = await Promise.all(
2213
2567
  projectRecords.flatMap(
2214
2568
  async (record) => Promise.all(
2215
2569
  record.assignments.map(
@@ -2218,11 +2572,48 @@ async function listAssignmentsBoard(projectsDir) {
2218
2572
  )
2219
2573
  )
2220
2574
  );
2575
+ const standaloneRecords = await listStandaloneRecords(assignmentsDir);
2576
+ const standaloneItems = await Promise.all(
2577
+ standaloneRecords.map(async (sr) => toStandaloneBoardItem(sr))
2578
+ );
2221
2579
  return {
2222
2580
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2223
- assignments: assignments.flat().sort((left, right) => compareTimestamps(right.updated, left.updated))
2581
+ assignments: [...projectItems.flat(), ...standaloneItems].sort((left, right) => compareTimestamps(right.updated, left.updated))
2582
+ };
2583
+ }
2584
+ async function toStandaloneBoardItem(sr) {
2585
+ return {
2586
+ ...toAssignmentSummary(sr.record),
2587
+ projectSlug: null,
2588
+ projectTitle: null,
2589
+ blockedReason: sr.record.blockedReason,
2590
+ projectWorkspace: null,
2591
+ availableTransitions: await getStandaloneAvailableTransitions(sr.record)
2224
2592
  };
2225
2593
  }
2594
+ async function getStandaloneAvailableTransitions(assignment) {
2595
+ const config = await getStatusConfig();
2596
+ const transitionDefs = getTransitionDefinitions(config);
2597
+ const actions = [];
2598
+ for (const definition of transitionDefs) {
2599
+ let warning = null;
2600
+ if (definition.command === "start" && !assignment.assignee) {
2601
+ warning = "No assignee set \u2014 consider assigning before starting.";
2602
+ }
2603
+ const target = getTargetStatus(assignment.status, definition.command, config.transitionTable);
2604
+ actions.push({
2605
+ command: definition.command,
2606
+ label: definition.label,
2607
+ description: definition.description,
2608
+ targetStatus: target ?? definition.command,
2609
+ disabled: false,
2610
+ disabledReason: null,
2611
+ warning,
2612
+ requiresReason: definition.requiresReason
2613
+ });
2614
+ }
2615
+ return actions;
2616
+ }
2226
2617
  async function getHelp() {
2227
2618
  return getDashboardHelp();
2228
2619
  }
@@ -2231,7 +2622,7 @@ async function getEditableDocument(projectsDir, documentType, projectSlug, assig
2231
2622
  if (!filePath || !await fileExists(filePath)) {
2232
2623
  return null;
2233
2624
  }
2234
- const content = await readFile5(filePath, "utf-8");
2625
+ const content = await readFile7(filePath, "utf-8");
2235
2626
  const title = getEditableDocumentTitle(documentType, projectSlug, assignmentSlug);
2236
2627
  return {
2237
2628
  documentType,
@@ -2242,16 +2633,44 @@ async function getEditableDocument(projectsDir, documentType, projectSlug, assig
2242
2633
  appendOnly: documentType === "handoff" || documentType === "decision-record"
2243
2634
  };
2244
2635
  }
2636
+ async function getEditableDocumentById(projectsDir, assignmentsDir, documentType, id) {
2637
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
2638
+ if (!resolved) return null;
2639
+ if (!resolved.standalone && resolved.projectSlug) {
2640
+ return getEditableDocument(
2641
+ projectsDir,
2642
+ documentType,
2643
+ resolved.projectSlug,
2644
+ resolved.assignmentSlug
2645
+ );
2646
+ }
2647
+ 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;
2648
+ if (!fileName) return null;
2649
+ const filePath = resolve8(resolved.assignmentDir, fileName);
2650
+ if (!await fileExists(filePath)) return null;
2651
+ const content = await readFile7(filePath, "utf-8");
2652
+ const label = resolved.id;
2653
+ 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}`;
2654
+ return {
2655
+ documentType,
2656
+ title,
2657
+ content,
2658
+ projectSlug: null,
2659
+ assignmentSlug: void 0,
2660
+ assignmentId: resolved.id,
2661
+ appendOnly: documentType === "handoff" || documentType === "decision-record"
2662
+ };
2663
+ }
2245
2664
  async function getProjectDetail(projectsDir, slug) {
2246
- const projectPath = resolve6(projectsDir, slug);
2247
- const projectMdPath = resolve6(projectPath, "project.md");
2665
+ const projectPath = resolve8(projectsDir, slug);
2666
+ const projectMdPath = resolve8(projectPath, "project.md");
2248
2667
  if (!await fileExists(projectMdPath)) {
2249
2668
  return null;
2250
2669
  }
2251
- const projectContent = await readFile5(projectMdPath, "utf-8");
2670
+ const projectContent = await readFile7(projectMdPath, "utf-8");
2252
2671
  const project = parseProject(projectContent);
2253
2672
  const assignments = await listAssignmentRecords(projectPath);
2254
- const rollup = buildProjectRollup(project, assignments);
2673
+ const rollup = await buildProjectRollup(projectPath, project, assignments);
2255
2674
  const dependencyGraph = await loadDependencyGraph(projectPath, assignments);
2256
2675
  const resources = await listResources(projectPath);
2257
2676
  const memories = await listMemories(projectPath);
@@ -2278,17 +2697,17 @@ async function getProjectDetail(projectsDir, slug) {
2278
2697
  };
2279
2698
  }
2280
2699
  async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
2281
- const assignmentDir = resolve6(projectsDir, projectSlug, "assignments", assignmentSlug);
2282
- const assignmentMdPath = resolve6(assignmentDir, "assignment.md");
2700
+ const assignmentDir = resolve8(projectsDir, projectSlug, "assignments", assignmentSlug);
2701
+ const assignmentMdPath = resolve8(assignmentDir, "assignment.md");
2283
2702
  if (!await fileExists(assignmentMdPath)) {
2284
2703
  return null;
2285
2704
  }
2286
- const assignmentContent = await readFile5(assignmentMdPath, "utf-8");
2705
+ const assignmentContent = await readFile7(assignmentMdPath, "utf-8");
2287
2706
  const assignment = parseAssignmentFull(assignmentContent);
2288
2707
  let plan = null;
2289
- const planPath = resolve6(assignmentDir, "plan.md");
2708
+ const planPath = resolve8(assignmentDir, "plan.md");
2290
2709
  if (await fileExists(planPath)) {
2291
- const planContent = await readFile5(planPath, "utf-8");
2710
+ const planContent = await readFile7(planPath, "utf-8");
2292
2711
  const parsed = parsePlan(planContent);
2293
2712
  plan = {
2294
2713
  status: parsed.status,
@@ -2297,9 +2716,9 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
2297
2716
  };
2298
2717
  }
2299
2718
  let scratchpad = null;
2300
- const scratchpadPath = resolve6(assignmentDir, "scratchpad.md");
2719
+ const scratchpadPath = resolve8(assignmentDir, "scratchpad.md");
2301
2720
  if (await fileExists(scratchpadPath)) {
2302
- const scratchpadContent = await readFile5(scratchpadPath, "utf-8");
2721
+ const scratchpadContent = await readFile7(scratchpadPath, "utf-8");
2303
2722
  const parsed = parseScratchpad(scratchpadContent);
2304
2723
  scratchpad = {
2305
2724
  updated: parsed.updated,
@@ -2307,9 +2726,9 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
2307
2726
  };
2308
2727
  }
2309
2728
  let handoff = null;
2310
- const handoffPath = resolve6(assignmentDir, "handoff.md");
2729
+ const handoffPath = resolve8(assignmentDir, "handoff.md");
2311
2730
  if (await fileExists(handoffPath)) {
2312
- const handoffContent = await readFile5(handoffPath, "utf-8");
2731
+ const handoffContent = await readFile7(handoffPath, "utf-8");
2313
2732
  const parsed = parseHandoff(handoffContent);
2314
2733
  handoff = {
2315
2734
  updated: parsed.updated,
@@ -2318,9 +2737,9 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
2318
2737
  };
2319
2738
  }
2320
2739
  let decisionRecord = null;
2321
- const decisionRecordPath = resolve6(assignmentDir, "decision-record.md");
2740
+ const decisionRecordPath = resolve8(assignmentDir, "decision-record.md");
2322
2741
  if (await fileExists(decisionRecordPath)) {
2323
- const decisionRecordContent = await readFile5(decisionRecordPath, "utf-8");
2742
+ const decisionRecordContent = await readFile7(decisionRecordPath, "utf-8");
2324
2743
  const parsed = parseDecisionRecord(decisionRecordContent);
2325
2744
  decisionRecord = {
2326
2745
  updated: parsed.updated,
@@ -2328,6 +2747,28 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
2328
2747
  body: parsed.body
2329
2748
  };
2330
2749
  }
2750
+ let progress = null;
2751
+ const progressPath = resolve8(assignmentDir, "progress.md");
2752
+ if (await fileExists(progressPath)) {
2753
+ const progressContent = await readFile7(progressPath, "utf-8");
2754
+ const parsed = parseProgress(progressContent);
2755
+ progress = {
2756
+ updated: parsed.updated,
2757
+ entryCount: parsed.entryCount,
2758
+ entries: parsed.entries
2759
+ };
2760
+ }
2761
+ let comments = null;
2762
+ const commentsPath = resolve8(assignmentDir, "comments.md");
2763
+ if (await fileExists(commentsPath)) {
2764
+ const commentsContent = await readFile7(commentsPath, "utf-8");
2765
+ const parsed = parseComments(commentsContent);
2766
+ comments = {
2767
+ updated: parsed.updated,
2768
+ entryCount: parsed.entryCount,
2769
+ entries: parsed.entries
2770
+ };
2771
+ }
2331
2772
  const detail = {
2332
2773
  id: assignment.id,
2333
2774
  projectSlug,
@@ -2351,6 +2792,9 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
2351
2792
  scratchpad,
2352
2793
  handoff,
2353
2794
  decisionRecord,
2795
+ progress,
2796
+ comments,
2797
+ referencedBy: [],
2354
2798
  availableTransitions: await getAvailableTransitions(
2355
2799
  projectsDir,
2356
2800
  projectSlug,
@@ -2414,85 +2858,276 @@ async function getAssignmentDetail(projectsDir, projectSlug, assignmentSlug) {
2414
2858
  });
2415
2859
  }
2416
2860
  detail.enrichedLinks = enrichedLinks;
2861
+ detail.referencedBy = await computeReferencedBy(
2862
+ { id: assignment.id, projectSlug, slug: detail.slug },
2863
+ projectsDir,
2864
+ void 0
2865
+ );
2417
2866
  return detail;
2418
2867
  }
2419
- async function listProjectRecords(projectsDir) {
2420
- if (!await fileExists(projectsDir)) {
2421
- return [];
2422
- }
2423
- const entries = await readdir3(projectsDir, { withFileTypes: true });
2424
- const projectDirs = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."));
2425
- const records = [];
2426
- for (const entry of projectDirs) {
2427
- const projectPath = resolve6(projectsDir, entry.name);
2428
- const projectMdPath = resolve6(projectPath, "project.md");
2429
- if (!await fileExists(projectMdPath)) {
2430
- continue;
2868
+ async function computeReferencedBy(target, projectsDir, assignmentsDir) {
2869
+ const sources = [];
2870
+ const projectRecords = await listProjectRecords(projectsDir);
2871
+ for (const rec of projectRecords) {
2872
+ for (const a of rec.assignments) {
2873
+ sources.push({
2874
+ id: a.id,
2875
+ slug: a.slug,
2876
+ title: a.title,
2877
+ projectSlug: rec.summary.slug,
2878
+ assignmentDir: resolve8(rec.projectPath, "assignments", a.slug)
2879
+ });
2431
2880
  }
2432
- const projectContent = await readFile5(projectMdPath, "utf-8");
2433
- const project = parseProject(projectContent);
2434
- const assignments = await listAssignmentRecords(projectPath);
2435
- const rollup = buildProjectRollup(project, assignments);
2436
- const updated = getProjectActivityTimestamp(project.updated, assignments);
2437
- records.push({
2438
- projectPath,
2439
- project,
2440
- assignments,
2441
- dependencyGraph: await loadDependencyGraph(projectPath, assignments),
2442
- summary: {
2443
- slug: project.slug || entry.name,
2444
- title: project.title,
2445
- status: rollup.status,
2446
- statusOverride: project.statusOverride,
2447
- archived: project.archived,
2448
- archivedAt: project.archivedAt,
2449
- archivedReason: project.archivedReason,
2450
- created: project.created,
2451
- updated,
2452
- tags: project.tags,
2453
- progress: rollup.progress,
2454
- needsAttention: rollup.needsAttention,
2455
- workspace: project.workspace
2456
- }
2881
+ }
2882
+ const standaloneRecords = await listStandaloneRecords(assignmentsDir);
2883
+ for (const sr of standaloneRecords) {
2884
+ sources.push({
2885
+ id: sr.id,
2886
+ slug: sr.record.slug || sr.id,
2887
+ title: sr.record.title,
2888
+ projectSlug: null,
2889
+ assignmentDir: sr.assignmentDir
2457
2890
  });
2458
2891
  }
2459
- records.sort((left, right) => compareTimestamps(right.summary.updated, left.summary.updated));
2460
- return records;
2892
+ const references = [];
2893
+ for (const source of sources) {
2894
+ if (source.id === target.id) continue;
2895
+ const mentions = await countMentionsInAssignment(source.assignmentDir, target);
2896
+ if (mentions > 0) {
2897
+ references.push({
2898
+ sourceId: source.id,
2899
+ sourceSlug: source.slug,
2900
+ sourceTitle: source.title,
2901
+ sourceProjectSlug: source.projectSlug,
2902
+ mentions
2903
+ });
2904
+ }
2905
+ if (references.length >= REFERENCED_BY_LIMIT) break;
2906
+ }
2907
+ return references.slice(0, REFERENCED_BY_LIMIT);
2461
2908
  }
2462
- async function listAssignmentRecords(projectPath) {
2463
- const assignmentsDir = resolve6(projectPath, "assignments");
2464
- if (!await fileExists(assignmentsDir)) {
2465
- return [];
2909
+ async function countMentionsInAssignment(sourceDir, target) {
2910
+ const bodies = [];
2911
+ const assignmentMd = resolve8(sourceDir, "assignment.md");
2912
+ if (await fileExists(assignmentMd)) {
2913
+ const content = await readFile7(assignmentMd, "utf-8");
2914
+ const todosMatch = content.match(/^## Todos\s*$([\s\S]*?)(?=^## |$(?![\r\n]))/m);
2915
+ if (todosMatch) bodies.push(todosMatch[1]);
2466
2916
  }
2467
- const entries = await readdir3(assignmentsDir, { withFileTypes: true });
2468
- const records = [];
2469
- for (const entry of entries) {
2470
- if (!entry.isDirectory()) {
2471
- continue;
2917
+ for (const filename of ["progress.md", "comments.md", "handoff.md"]) {
2918
+ const path = resolve8(sourceDir, filename);
2919
+ if (await fileExists(path)) {
2920
+ try {
2921
+ bodies.push(await readFile7(path, "utf-8"));
2922
+ } catch {
2923
+ }
2472
2924
  }
2473
- const assignmentMd = resolve6(assignmentsDir, entry.name, "assignment.md");
2474
- if (!await fileExists(assignmentMd)) {
2475
- continue;
2925
+ }
2926
+ let total = 0;
2927
+ const patterns = buildLinkPatternsForTarget(target);
2928
+ for (const body of bodies) {
2929
+ for (const pattern of patterns) {
2930
+ const matches = body.match(pattern);
2931
+ if (matches) total += matches.length;
2476
2932
  }
2477
- const content = await readFile5(assignmentMd, "utf-8");
2478
- records.push(parseAssignmentFull(content));
2933
+ }
2934
+ return total;
2935
+ }
2936
+ function buildLinkPatternsForTarget(target) {
2937
+ const patterns = [];
2938
+ patterns.push(new RegExp(`/assignments/${escapeRegExpLocal(target.id)}(?:/|\\b)`, "g"));
2939
+ if (target.projectSlug) {
2940
+ patterns.push(
2941
+ new RegExp(
2942
+ `/projects/${escapeRegExpLocal(target.projectSlug)}/assignments/${escapeRegExpLocal(target.slug)}(?:/|\\b)`,
2943
+ "g"
2944
+ )
2945
+ );
2946
+ patterns.push(
2947
+ new RegExp(`\\.\\./${escapeRegExpLocal(target.slug)}(?:/|\\b)`, "g")
2948
+ );
2949
+ }
2950
+ return patterns;
2951
+ }
2952
+ function escapeRegExpLocal(value) {
2953
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2954
+ }
2955
+ async function getAssignmentDetailById(projectsDir, assignmentsDir, id) {
2956
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
2957
+ if (!resolved) return null;
2958
+ if (!resolved.standalone && resolved.projectSlug) {
2959
+ const detail = await getAssignmentDetail(projectsDir, resolved.projectSlug, resolved.assignmentSlug);
2960
+ if (!detail) return null;
2961
+ detail.referencedBy = await computeReferencedBy(
2962
+ { id: detail.id, projectSlug: detail.projectSlug, slug: detail.slug },
2963
+ projectsDir,
2964
+ assignmentsDir
2965
+ );
2966
+ return detail;
2967
+ }
2968
+ const standaloneDetail = await buildStandaloneAssignmentDetail(resolved);
2969
+ if (!standaloneDetail) return null;
2970
+ standaloneDetail.referencedBy = await computeReferencedBy(
2971
+ { id: standaloneDetail.id, projectSlug: null, slug: standaloneDetail.slug },
2972
+ projectsDir,
2973
+ assignmentsDir
2974
+ );
2975
+ return standaloneDetail;
2976
+ }
2977
+ async function buildStandaloneAssignmentDetail(resolved) {
2978
+ const assignmentDir = resolved.assignmentDir;
2979
+ const assignmentMdPath = resolve8(assignmentDir, "assignment.md");
2980
+ if (!await fileExists(assignmentMdPath)) return null;
2981
+ const assignmentContent = await readFile7(assignmentMdPath, "utf-8");
2982
+ const assignment = parseAssignmentFull(assignmentContent);
2983
+ let plan = null;
2984
+ const planPath = resolve8(assignmentDir, "plan.md");
2985
+ if (await fileExists(planPath)) {
2986
+ const parsed = parsePlan(await readFile7(planPath, "utf-8"));
2987
+ plan = { status: parsed.status, updated: parsed.updated, body: parsed.body };
2988
+ }
2989
+ let scratchpad = null;
2990
+ const scratchpadPath = resolve8(assignmentDir, "scratchpad.md");
2991
+ if (await fileExists(scratchpadPath)) {
2992
+ const parsed = parseScratchpad(await readFile7(scratchpadPath, "utf-8"));
2993
+ scratchpad = { updated: parsed.updated, body: parsed.body };
2994
+ }
2995
+ let handoff = null;
2996
+ const handoffPath = resolve8(assignmentDir, "handoff.md");
2997
+ if (await fileExists(handoffPath)) {
2998
+ const parsed = parseHandoff(await readFile7(handoffPath, "utf-8"));
2999
+ handoff = { updated: parsed.updated, handoffCount: parsed.handoffCount, body: parsed.body };
3000
+ }
3001
+ let decisionRecord = null;
3002
+ const decisionRecordPath = resolve8(assignmentDir, "decision-record.md");
3003
+ if (await fileExists(decisionRecordPath)) {
3004
+ const parsed = parseDecisionRecord(await readFile7(decisionRecordPath, "utf-8"));
3005
+ decisionRecord = { updated: parsed.updated, decisionCount: parsed.decisionCount, body: parsed.body };
3006
+ }
3007
+ let progress = null;
3008
+ const progressPath = resolve8(assignmentDir, "progress.md");
3009
+ if (await fileExists(progressPath)) {
3010
+ const parsed = parseProgress(await readFile7(progressPath, "utf-8"));
3011
+ progress = { updated: parsed.updated, entryCount: parsed.entryCount, entries: parsed.entries };
3012
+ }
3013
+ let comments = null;
3014
+ const commentsPath = resolve8(assignmentDir, "comments.md");
3015
+ if (await fileExists(commentsPath)) {
3016
+ const parsed = parseComments(await readFile7(commentsPath, "utf-8"));
3017
+ comments = { updated: parsed.updated, entryCount: parsed.entryCount, entries: parsed.entries };
3018
+ }
3019
+ const detail = {
3020
+ id: assignment.id,
3021
+ projectSlug: null,
3022
+ slug: assignment.slug || resolved.id,
3023
+ title: assignment.title,
3024
+ status: assignment.status,
3025
+ priority: assignment.priority,
3026
+ assignee: assignment.assignee,
3027
+ dependsOn: [],
3028
+ // standalone cannot declare dependencies
3029
+ links: [],
3030
+ reverseLinks: [],
3031
+ enrichedLinks: [],
3032
+ blockedReason: assignment.blockedReason,
3033
+ workspace: assignment.workspace,
3034
+ externalIds: assignment.externalIds,
3035
+ tags: assignment.tags,
3036
+ created: assignment.created,
3037
+ updated: assignment.updated,
3038
+ body: assignment.body,
3039
+ plan,
3040
+ scratchpad,
3041
+ handoff,
3042
+ decisionRecord,
3043
+ progress,
3044
+ comments,
3045
+ referencedBy: [],
3046
+ availableTransitions: await getStandaloneAvailableTransitions(assignment)
3047
+ };
3048
+ return detail;
3049
+ }
3050
+ async function listProjectRecords(projectsDir) {
3051
+ if (!await fileExists(projectsDir)) {
3052
+ return [];
3053
+ }
3054
+ if (!migratedProjectsDirs.has(projectsDir)) {
3055
+ migratedProjectsDirs.add(projectsDir);
3056
+ await migrateLegacyProjectFiles(projectsDir);
3057
+ }
3058
+ const entries = await readdir5(projectsDir, { withFileTypes: true });
3059
+ const projectDirs = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."));
3060
+ const records = [];
3061
+ for (const entry of projectDirs) {
3062
+ const projectPath = resolve8(projectsDir, entry.name);
3063
+ const projectMdPath = resolve8(projectPath, "project.md");
3064
+ if (!await fileExists(projectMdPath)) {
3065
+ continue;
3066
+ }
3067
+ const projectContent = await readFile7(projectMdPath, "utf-8");
3068
+ const project = parseProject(projectContent);
3069
+ const assignments = await listAssignmentRecords(projectPath);
3070
+ const rollup = await buildProjectRollup(projectPath, project, assignments);
3071
+ const updated = getProjectActivityTimestamp(project.updated, assignments);
3072
+ records.push({
3073
+ projectPath,
3074
+ project,
3075
+ assignments,
3076
+ dependencyGraph: await loadDependencyGraph(projectPath, assignments),
3077
+ summary: {
3078
+ slug: project.slug || entry.name,
3079
+ title: project.title,
3080
+ status: rollup.status,
3081
+ statusOverride: project.statusOverride,
3082
+ archived: project.archived,
3083
+ archivedAt: project.archivedAt,
3084
+ archivedReason: project.archivedReason,
3085
+ created: project.created,
3086
+ updated,
3087
+ tags: project.tags,
3088
+ progress: rollup.progress,
3089
+ needsAttention: rollup.needsAttention,
3090
+ workspace: project.workspace
3091
+ }
3092
+ });
3093
+ }
3094
+ records.sort((left, right) => compareTimestamps(right.summary.updated, left.summary.updated));
3095
+ return records;
3096
+ }
3097
+ async function listAssignmentRecords(projectPath) {
3098
+ const assignmentsDir = resolve8(projectPath, "assignments");
3099
+ if (!await fileExists(assignmentsDir)) {
3100
+ return [];
3101
+ }
3102
+ const entries = await readdir5(assignmentsDir, { withFileTypes: true });
3103
+ const records = [];
3104
+ for (const entry of entries) {
3105
+ if (!entry.isDirectory()) {
3106
+ continue;
3107
+ }
3108
+ const assignmentMd = resolve8(assignmentsDir, entry.name, "assignment.md");
3109
+ if (!await fileExists(assignmentMd)) {
3110
+ continue;
3111
+ }
3112
+ const content = await readFile7(assignmentMd, "utf-8");
3113
+ records.push(parseAssignmentFull(content));
2479
3114
  }
2480
3115
  records.sort((left, right) => compareTimestamps(right.updated, left.updated));
2481
3116
  return records;
2482
3117
  }
2483
3118
  async function listResources(projectPath) {
2484
- const resourcesDir = resolve6(projectPath, "resources");
3119
+ const resourcesDir = resolve8(projectPath, "resources");
2485
3120
  if (!await fileExists(resourcesDir)) {
2486
3121
  return [];
2487
3122
  }
2488
- const entries = await readdir3(resourcesDir, { withFileTypes: true });
3123
+ const entries = await readdir5(resourcesDir, { withFileTypes: true });
2489
3124
  const results = [];
2490
3125
  for (const entry of entries) {
2491
3126
  if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith("_")) {
2492
3127
  continue;
2493
3128
  }
2494
- const filePath = resolve6(resourcesDir, entry.name);
2495
- const content = await readFile5(filePath, "utf-8");
3129
+ const filePath = resolve8(resourcesDir, entry.name);
3130
+ const content = await readFile7(filePath, "utf-8");
2496
3131
  const parsed = parseResource(content);
2497
3132
  results.push({
2498
3133
  name: parsed.name,
@@ -2507,18 +3142,18 @@ async function listResources(projectPath) {
2507
3142
  return results;
2508
3143
  }
2509
3144
  async function listMemories(projectPath) {
2510
- const memoriesDir = resolve6(projectPath, "memories");
3145
+ const memoriesDir = resolve8(projectPath, "memories");
2511
3146
  if (!await fileExists(memoriesDir)) {
2512
3147
  return [];
2513
3148
  }
2514
- const entries = await readdir3(memoriesDir, { withFileTypes: true });
3149
+ const entries = await readdir5(memoriesDir, { withFileTypes: true });
2515
3150
  const results = [];
2516
3151
  for (const entry of entries) {
2517
3152
  if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith("_")) {
2518
3153
  continue;
2519
3154
  }
2520
- const filePath = resolve6(memoriesDir, entry.name);
2521
- const content = await readFile5(filePath, "utf-8");
3155
+ const filePath = resolve8(memoriesDir, entry.name);
3156
+ const content = await readFile7(filePath, "utf-8");
2522
3157
  const parsed = parseMemory(content);
2523
3158
  results.push({
2524
3159
  name: parsed.name,
@@ -2533,9 +3168,9 @@ async function listMemories(projectPath) {
2533
3168
  return results;
2534
3169
  }
2535
3170
  async function loadDependencyGraph(projectPath, assignments) {
2536
- const statusPath = resolve6(projectPath, "_status.md");
3171
+ const statusPath = resolve8(projectPath, "_status.md");
2537
3172
  if (await fileExists(statusPath)) {
2538
- const statusContent = await readFile5(statusPath, "utf-8");
3173
+ const statusContent = await readFile7(statusPath, "utf-8");
2539
3174
  const parsed = parseStatus(statusContent);
2540
3175
  const derivedGraph = extractMermaidGraph(parsed.body);
2541
3176
  if (derivedGraph) {
@@ -2544,13 +3179,13 @@ async function loadDependencyGraph(projectPath, assignments) {
2544
3179
  }
2545
3180
  return buildDependencyGraph(assignments);
2546
3181
  }
2547
- function buildProjectRollup(project, assignments) {
3182
+ async function buildProjectRollup(projectPath, project, assignments) {
2548
3183
  const progress = { total: assignments.length };
2549
3184
  let openQuestions = 0;
2550
3185
  for (const assignment of assignments) {
2551
3186
  const s = assignment.status;
2552
3187
  progress[s] = (progress[s] ?? 0) + 1;
2553
- openQuestions += countPendingAnswers(assignment.body);
3188
+ openQuestions += await countOpenQuestions(projectPath, assignment.slug);
2554
3189
  }
2555
3190
  const needsAttention = {
2556
3191
  blockedCount: progress["blocked"] ?? 0,
@@ -2635,7 +3270,7 @@ async function getAvailableTransitions(projectsDir, projectSlug, assignmentSlug,
2635
3270
  const config = await getStatusConfig();
2636
3271
  const transitionDefs = getTransitionDefinitions(config);
2637
3272
  const actions = [];
2638
- const projectPath = resolve6(projectsDir, projectSlug);
3273
+ const projectPath = resolve8(projectsDir, projectSlug);
2639
3274
  for (const definition of transitionDefs) {
2640
3275
  let warning = null;
2641
3276
  if (definition.command === "start" && !assignment.assignee) {
@@ -2665,12 +3300,12 @@ async function getUnmetDependencies(projectPath, dependsOn, terminalStatuses) {
2665
3300
  const terminals = terminalStatuses ?? /* @__PURE__ */ new Set(["completed"]);
2666
3301
  const unmet = [];
2667
3302
  for (const dependency of dependsOn) {
2668
- const dependencyPath = resolve6(projectPath, "assignments", dependency, "assignment.md");
3303
+ const dependencyPath = resolve8(projectPath, "assignments", dependency, "assignment.md");
2669
3304
  if (!await fileExists(dependencyPath)) {
2670
3305
  unmet.push(`${dependency} (missing)`);
2671
3306
  continue;
2672
3307
  }
2673
- const content = await readFile5(dependencyPath, "utf-8");
3308
+ const content = await readFile7(dependencyPath, "utf-8");
2674
3309
  const parsed = parseAssignmentFull(content);
2675
3310
  if (!terminals.has(parsed.status)) {
2676
3311
  unmet.push(`${dependency} (${parsed.status})`);
@@ -2678,7 +3313,7 @@ async function getUnmetDependencies(projectPath, dependsOn, terminalStatuses) {
2678
3313
  }
2679
3314
  return unmet;
2680
3315
  }
2681
- function buildAttentionItems(projectRecords) {
3316
+ function buildAttentionItems(projectRecords, standaloneRecords = []) {
2682
3317
  const items = [];
2683
3318
  for (const record of projectRecords) {
2684
3319
  for (const assignment of record.assignments) {
@@ -2728,9 +3363,36 @@ function buildAttentionItems(projectRecords) {
2728
3363
  }
2729
3364
  }
2730
3365
  }
3366
+ for (const sr of standaloneRecords) {
3367
+ const assignment = sr.record;
3368
+ const stale = isStale(assignment.updated);
3369
+ const base = {
3370
+ projectSlug: null,
3371
+ projectTitle: null,
3372
+ assignmentSlug: assignment.slug || sr.id,
3373
+ assignmentTitle: assignment.title,
3374
+ status: assignment.status,
3375
+ updated: assignment.updated,
3376
+ href: `/assignments/${sr.id}`,
3377
+ blockedReason: assignment.blockedReason,
3378
+ stale
3379
+ };
3380
+ if (assignment.status === "failed") {
3381
+ items.push({ id: `standalone:${sr.id}:failed`, severity: "critical", reason: "Marked failed and needs a recovery decision.", ...base });
3382
+ }
3383
+ if (assignment.status === "blocked") {
3384
+ items.push({ id: `standalone:${sr.id}:blocked`, severity: "high", reason: assignment.blockedReason || "Blocked and waiting for intervention.", ...base });
3385
+ }
3386
+ if (assignment.status === "review") {
3387
+ items.push({ id: `standalone:${sr.id}:review`, severity: "medium", reason: "Ready for review.", ...base });
3388
+ }
3389
+ if (stale) {
3390
+ items.push({ id: `standalone:${sr.id}:stale`, severity: "low", reason: "No source updates have been recorded in the last 7 days.", ...base });
3391
+ }
3392
+ }
2731
3393
  return items.sort(compareAttentionItems);
2732
3394
  }
2733
- function buildRecentActivity(projectRecords) {
3395
+ function buildRecentActivity(projectRecords, standaloneRecords = []) {
2734
3396
  const activity = [];
2735
3397
  for (const record of projectRecords) {
2736
3398
  activity.push({
@@ -2758,6 +3420,20 @@ function buildRecentActivity(projectRecords) {
2758
3420
  });
2759
3421
  }
2760
3422
  }
3423
+ for (const sr of standaloneRecords) {
3424
+ const assignment = sr.record;
3425
+ activity.push({
3426
+ id: `standalone-assignment:${sr.id}`,
3427
+ type: "assignment",
3428
+ title: assignment.title,
3429
+ updated: assignment.updated,
3430
+ href: `/assignments/${sr.id}`,
3431
+ projectSlug: null,
3432
+ projectTitle: null,
3433
+ assignmentSlug: assignment.slug || sr.id,
3434
+ summary: `Standalone assignment is ${assignment.status} with ${assignment.priority} priority.`
3435
+ });
3436
+ }
2761
3437
  activity.sort((left, right) => compareTimestamps(right.updated, left.updated));
2762
3438
  return activity;
2763
3439
  }
@@ -2783,9 +3459,25 @@ function isStale(updated) {
2783
3459
  }
2784
3460
  return Date.now() - timestamp > STALE_ASSIGNMENT_MS;
2785
3461
  }
2786
- function countPendingAnswers(body) {
2787
- const matches = body.match(/^\*\*A:\*\*\s+pending\s*$/gim);
2788
- return matches ? matches.length : 0;
3462
+ async function countOpenQuestions(projectPath, assignmentSlug) {
3463
+ const commentsPath = resolve8(
3464
+ projectPath,
3465
+ "assignments",
3466
+ assignmentSlug,
3467
+ "comments.md"
3468
+ );
3469
+ if (!await fileExists(commentsPath)) {
3470
+ return 0;
3471
+ }
3472
+ try {
3473
+ const content = await readFile7(commentsPath, "utf-8");
3474
+ const parsed = parseComments(content);
3475
+ return parsed.entries.filter(
3476
+ (e) => e.type === "question" && e.resolved !== true
3477
+ ).length;
3478
+ } catch {
3479
+ return 0;
3480
+ }
2789
3481
  }
2790
3482
  function getProjectActivityTimestamp(projectUpdated, assignments) {
2791
3483
  let latest = projectUpdated;
@@ -2799,17 +3491,17 @@ function getProjectActivityTimestamp(projectUpdated, assignments) {
2799
3491
  function getDocumentPath(projectsDir, documentType, projectSlug, assignmentSlug) {
2800
3492
  switch (documentType) {
2801
3493
  case "project":
2802
- return resolve6(projectsDir, projectSlug, "project.md");
3494
+ return resolve8(projectsDir, projectSlug, "project.md");
2803
3495
  case "assignment":
2804
- return assignmentSlug ? resolve6(projectsDir, projectSlug, "assignments", assignmentSlug, "assignment.md") : null;
3496
+ return assignmentSlug ? resolve8(projectsDir, projectSlug, "assignments", assignmentSlug, "assignment.md") : null;
2805
3497
  case "plan":
2806
- return assignmentSlug ? resolve6(projectsDir, projectSlug, "assignments", assignmentSlug, "plan.md") : null;
3498
+ return assignmentSlug ? resolve8(projectsDir, projectSlug, "assignments", assignmentSlug, "plan.md") : null;
2807
3499
  case "scratchpad":
2808
- return assignmentSlug ? resolve6(projectsDir, projectSlug, "assignments", assignmentSlug, "scratchpad.md") : null;
3500
+ return assignmentSlug ? resolve8(projectsDir, projectSlug, "assignments", assignmentSlug, "scratchpad.md") : null;
2809
3501
  case "handoff":
2810
- return assignmentSlug ? resolve6(projectsDir, projectSlug, "assignments", assignmentSlug, "handoff.md") : null;
3502
+ return assignmentSlug ? resolve8(projectsDir, projectSlug, "assignments", assignmentSlug, "handoff.md") : null;
2811
3503
  case "decision-record":
2812
- return assignmentSlug ? resolve6(projectsDir, projectSlug, "assignments", assignmentSlug, "decision-record.md") : null;
3504
+ return assignmentSlug ? resolve8(projectsDir, projectSlug, "assignments", assignmentSlug, "decision-record.md") : null;
2813
3505
  default:
2814
3506
  return null;
2815
3507
  }
@@ -2836,12 +3528,12 @@ function getEditableDocumentTitle(documentType, projectSlug, assignmentSlug) {
2836
3528
  }
2837
3529
  async function listPlaybooks(playbooksDir2) {
2838
3530
  if (!await fileExists(playbooksDir2)) return [];
2839
- const entries = await readdir3(playbooksDir2, { withFileTypes: true });
3531
+ const entries = await readdir5(playbooksDir2, { withFileTypes: true });
2840
3532
  const playbooks = [];
2841
3533
  for (const entry of entries) {
2842
3534
  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");
3535
+ const filePath = resolve8(playbooksDir2, entry.name);
3536
+ const raw = await readFile7(filePath, "utf-8");
2845
3537
  const parsed = parsePlaybook(raw);
2846
3538
  const slug = parsed.slug || entry.name.replace(/\.md$/, "");
2847
3539
  playbooks.push({
@@ -2857,9 +3549,9 @@ async function listPlaybooks(playbooksDir2) {
2857
3549
  return playbooks.sort((a, b) => (b.updated || b.created).localeCompare(a.updated || a.created));
2858
3550
  }
2859
3551
  async function getPlaybookDetail(playbooksDir2, slug) {
2860
- const filePath = resolve6(playbooksDir2, `${slug}.md`);
3552
+ const filePath = resolve8(playbooksDir2, `${slug}.md`);
2861
3553
  if (!await fileExists(filePath)) return null;
2862
- const raw = await readFile5(filePath, "utf-8");
3554
+ const raw = await readFile7(filePath, "utf-8");
2863
3555
  const parsed = parsePlaybook(raw);
2864
3556
  return {
2865
3557
  slug: parsed.slug || slug,
@@ -2872,13 +3564,15 @@ async function getPlaybookDetail(playbooksDir2, slug) {
2872
3564
  body: parsed.body
2873
3565
  };
2874
3566
  }
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;
3567
+ 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, migratedProjectsDirs, DEFAULT_GRAPH_COLORS;
2876
3568
  var init_api = __esm({
2877
3569
  "src/dashboard/api.ts"() {
2878
3570
  "use strict";
2879
3571
  init_lifecycle();
2880
3572
  init_fs();
2881
3573
  init_config2();
3574
+ init_fs_migration();
3575
+ init_assignment_resolver();
2882
3576
  init_parser();
2883
3577
  init_help();
2884
3578
  STALE_ASSIGNMENT_MS = 7 * 24 * 60 * 60 * 1e3;
@@ -2939,6 +3633,8 @@ var init_api = __esm({
2939
3633
  failed: "rose"
2940
3634
  };
2941
3635
  _cachedConfig = null;
3636
+ REFERENCED_BY_LIMIT = 50;
3637
+ migratedProjectsDirs = /* @__PURE__ */ new Set();
2942
3638
  DEFAULT_GRAPH_COLORS = {
2943
3639
  completed: "fill:#4ea84f,stroke:#1f6b29,color:#ffffff",
2944
3640
  in_progress: "fill:#1e6fd9,stroke:#0f3f8f,color:#ffffff",
@@ -2971,8 +3667,8 @@ __export(parser_exports, {
2971
3667
  writeChecklist: () => writeChecklist
2972
3668
  });
2973
3669
  import { randomBytes } from "crypto";
2974
- import { readFile as readFile10 } from "fs/promises";
2975
- import { resolve as resolve13 } from "path";
3670
+ import { readFile as readFile12 } from "fs/promises";
3671
+ import { resolve as resolve15 } from "path";
2976
3672
  function generateShortId() {
2977
3673
  return randomBytes(2).toString("hex");
2978
3674
  }
@@ -3132,10 +3828,10 @@ function serializeLogEntry(entry) {
3132
3828
  return lines.join("\n");
3133
3829
  }
3134
3830
  function checklistPath(todosDir2, workspace) {
3135
- return resolve13(todosDir2, `${workspace}.md`);
3831
+ return resolve15(todosDir2, `${workspace}.md`);
3136
3832
  }
3137
3833
  function logPath(todosDir2, workspace) {
3138
- return resolve13(todosDir2, `${workspace}-log.md`);
3834
+ return resolve15(todosDir2, `${workspace}-log.md`);
3139
3835
  }
3140
3836
  function archivePath(todosDir2, workspace, interval, now = /* @__PURE__ */ new Date()) {
3141
3837
  const year = now.getFullYear();
@@ -3159,14 +3855,14 @@ function archivePath(todosDir2, workspace, interval, now = /* @__PURE__ */ new D
3159
3855
  default:
3160
3856
  suffix = `${year}-${month}-${day}`;
3161
3857
  }
3162
- return resolve13(todosDir2, "archive", `${workspace}-${suffix}.md`);
3858
+ return resolve15(todosDir2, "archive", `${workspace}-${suffix}.md`);
3163
3859
  }
3164
3860
  async function readChecklist(todosDir2, workspace) {
3165
3861
  const path = checklistPath(todosDir2, workspace);
3166
3862
  if (!await fileExists(path)) {
3167
3863
  return { workspace, archiveInterval: "weekly", items: [] };
3168
3864
  }
3169
- const content = await readFile10(path, "utf-8");
3865
+ const content = await readFile12(path, "utf-8");
3170
3866
  return parseChecklist(content);
3171
3867
  }
3172
3868
  async function writeChecklist(todosDir2, checklist) {
@@ -3179,7 +3875,7 @@ async function readLog(todosDir2, workspace) {
3179
3875
  if (!await fileExists(path)) {
3180
3876
  return { workspace, entries: [] };
3181
3877
  }
3182
- const content = await readFile10(path, "utf-8");
3878
+ const content = await readFile12(path, "utf-8");
3183
3879
  return parseLog(content);
3184
3880
  }
3185
3881
  async function appendLogEntry2(todosDir2, workspace, entry) {
@@ -3187,7 +3883,7 @@ async function appendLogEntry2(todosDir2, workspace, entry) {
3187
3883
  const path = logPath(todosDir2, workspace);
3188
3884
  let content;
3189
3885
  if (await fileExists(path)) {
3190
- content = await readFile10(path, "utf-8");
3886
+ content = await readFile12(path, "utf-8");
3191
3887
  content = content.trimEnd() + "\n\n" + serializeLogEntry(entry) + "\n";
3192
3888
  } else {
3193
3889
  const fm = `---
@@ -3223,93 +3919,459 @@ var init_parser2 = __esm({
3223
3919
  // src/dashboard/server.ts
3224
3920
  init_paths();
3225
3921
  init_api();
3922
+ init_assignment_resolver();
3226
3923
  import express from "express";
3227
3924
  import { createServer } from "http";
3228
- import { resolve as resolve15 } from "path";
3229
- import { writeFile as writeFile4, unlink as unlink4 } from "fs/promises";
3925
+ import { resolve as resolve17 } from "path";
3926
+ import { writeFile as writeFile5, unlink as unlink4 } from "fs/promises";
3230
3927
  import { WebSocketServer, WebSocket } from "ws";
3231
3928
 
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];
3929
+ // src/dashboard/agent-sessions.ts
3930
+ init_fs();
3931
+ import { readFile as readFile8 } from "fs/promises";
3932
+ import { resolve as resolve10 } from "path";
3933
+
3934
+ // src/dashboard/session-db.ts
3935
+ init_paths();
3936
+ init_fs();
3937
+ import Database from "better-sqlite3";
3938
+ import { resolve as resolve9 } from "path";
3939
+ import { readdir as readdir6 } from "fs/promises";
3940
+ var db = null;
3941
+ var SCHEMA_VERSION = "3";
3942
+ var SCHEMA_SQL = `
3943
+ CREATE TABLE IF NOT EXISTS sessions (
3944
+ session_id TEXT PRIMARY KEY,
3945
+ project_slug TEXT,
3946
+ assignment_slug TEXT,
3947
+ agent TEXT NOT NULL,
3948
+ started TEXT NOT NULL,
3949
+ ended TEXT,
3950
+ status TEXT NOT NULL DEFAULT 'active',
3951
+ path TEXT,
3952
+ description TEXT,
3953
+ transcript_path TEXT,
3954
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
3955
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
3956
+ );
3957
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
3958
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
3959
+ `;
3960
+ var POST_MIGRATION_INDEXES_SQL = `
3961
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
3962
+ CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
3963
+ `;
3964
+ function initSessionDb(dbPath) {
3965
+ if (db) return db;
3966
+ const finalPath = dbPath ?? resolve9(syntaurRoot(), "syntaur.db");
3967
+ db = new Database(finalPath);
3968
+ db.pragma("journal_mode = WAL");
3969
+ db.exec(SCHEMA_SQL);
3970
+ db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
3971
+ "schema_version",
3972
+ SCHEMA_VERSION
3973
+ );
3974
+ const database = db;
3975
+ const runMigrations = database.transaction(() => {
3976
+ const vBeforeV2 = database.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value;
3977
+ if (vBeforeV2 === "1") {
3978
+ database.exec(`
3979
+ CREATE TABLE sessions_v2 (
3980
+ session_id TEXT PRIMARY KEY,
3981
+ project_slug TEXT,
3982
+ assignment_slug TEXT,
3983
+ agent TEXT NOT NULL,
3984
+ started TEXT NOT NULL,
3985
+ ended TEXT,
3986
+ status TEXT NOT NULL DEFAULT 'active',
3987
+ path TEXT,
3988
+ description TEXT,
3989
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
3990
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
3991
+ );
3992
+ INSERT INTO sessions_v2 SELECT session_id, project_slug, assignment_slug, agent, started, ended, status, path, NULL, created_at, updated_at FROM sessions;
3993
+ DROP TABLE sessions;
3994
+ ALTER TABLE sessions_v2 RENAME TO sessions;
3995
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
3996
+ CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
3997
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
3998
+ UPDATE meta SET value = '2' WHERE key = 'schema_version';
3999
+ `);
4000
+ }
4001
+ const vBeforeV3 = database.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value;
4002
+ if (vBeforeV3 === "2") {
4003
+ const v2Columns = database.prepare("PRAGMA table_info(sessions)").all();
4004
+ const v2ColNames = v2Columns.map((c) => c.name);
4005
+ const hasProject = v2ColNames.includes("project_slug");
4006
+ const hasMission = v2ColNames.includes("mission_slug");
4007
+ const projectSlugExpr = hasProject && hasMission ? "COALESCE(project_slug, mission_slug)" : hasProject ? "project_slug" : hasMission ? "mission_slug" : null;
4008
+ if (!projectSlugExpr) {
4009
+ throw new Error(
4010
+ "sessions table has neither project_slug nor mission_slug; cannot migrate from v2 to v3"
4011
+ );
4012
+ }
4013
+ database.exec(`
4014
+ CREATE TABLE sessions_v3 (
4015
+ session_id TEXT PRIMARY KEY,
4016
+ project_slug TEXT,
4017
+ assignment_slug TEXT,
4018
+ agent TEXT NOT NULL,
4019
+ started TEXT NOT NULL,
4020
+ ended TEXT,
4021
+ status TEXT NOT NULL DEFAULT 'active',
4022
+ path TEXT,
4023
+ description TEXT,
4024
+ transcript_path TEXT,
4025
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
4026
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
4027
+ );
4028
+ INSERT INTO sessions_v3
4029
+ SELECT session_id, ${projectSlugExpr}, assignment_slug, agent, started, ended, status, path, description, NULL, created_at, updated_at
4030
+ FROM sessions;
4031
+ DROP TABLE sessions;
4032
+ ALTER TABLE sessions_v3 RENAME TO sessions;
4033
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
4034
+ CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
4035
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
4036
+ UPDATE meta SET value = '3' WHERE key = 'schema_version';
4037
+ `);
3252
4038
  }
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)
4039
+ });
4040
+ runMigrations.exclusive();
4041
+ db.exec(POST_MIGRATION_INDEXES_SQL);
4042
+ return db;
4043
+ }
4044
+ function getSessionDb() {
4045
+ if (!db) {
4046
+ throw new Error(
4047
+ "Session database not initialized. Call initSessionDb() first."
3269
4048
  );
3270
4049
  }
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);
4050
+ return db;
4051
+ }
4052
+ function closeSessionDb() {
4053
+ if (db) {
4054
+ db.close();
4055
+ db = null;
3302
4056
  }
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);
4057
+ }
4058
+ async function migrateFromMarkdown(projectsDir) {
4059
+ const database = getSessionDb();
4060
+ const count = database.prepare("SELECT COUNT(*) as count FROM sessions").get();
4061
+ if (count.count > 0) return 0;
4062
+ if (!await fileExists(projectsDir)) return 0;
4063
+ const entries = await readdir6(projectsDir, { withFileTypes: true });
4064
+ const allSessions = [];
4065
+ for (const entry of entries) {
4066
+ if (!entry.isDirectory()) continue;
4067
+ const projectDir = resolve9(projectsDir, entry.name);
4068
+ const indexPath = resolve9(projectDir, "_index-sessions.md");
4069
+ if (!await fileExists(indexPath)) continue;
4070
+ const sessions = await parseMarkdownSessionsIndex(indexPath, entry.name);
4071
+ allSessions.push(...sessions);
4072
+ }
4073
+ if (allSessions.length === 0) return 0;
4074
+ const insert = database.prepare(`
4075
+ INSERT OR IGNORE INTO sessions (session_id, project_slug, assignment_slug, agent, started, status, path)
4076
+ VALUES (?, ?, ?, ?, ?, ?, ?)
4077
+ `);
4078
+ const insertAll = database.transaction((sessions) => {
4079
+ for (const s of sessions) {
4080
+ insert.run(s.sessionId, s.projectSlug, s.assignmentSlug, s.agent, s.started, s.status, s.path);
4081
+ }
4082
+ });
4083
+ insertAll(allSessions);
4084
+ console.log(`Migrated ${allSessions.length} sessions from markdown to SQLite.`);
4085
+ return allSessions.length;
4086
+ }
4087
+ async function parseMarkdownSessionsIndex(filePath, projectSlug) {
4088
+ const { readFile: readFile14 } = await import("fs/promises");
4089
+ const raw = await readFile14(filePath, "utf-8");
4090
+ const sessions = [];
4091
+ const lines = raw.split("\n");
4092
+ let inTable = false;
4093
+ let headerSeen = false;
4094
+ for (const line of lines) {
4095
+ const trimmed = line.trim();
4096
+ if (!trimmed) continue;
4097
+ if (trimmed.startsWith("| Assignment") || trimmed.startsWith("|Assignment")) {
4098
+ inTable = true;
4099
+ headerSeen = false;
4100
+ continue;
4101
+ }
4102
+ if (inTable && !headerSeen && trimmed.match(/^\|[-\s|]+\|$/)) {
4103
+ headerSeen = true;
4104
+ continue;
4105
+ }
4106
+ if (inTable && headerSeen && trimmed.startsWith("|")) {
4107
+ const cells = trimmed.split("|").slice(1, -1).map((c) => c.trim());
4108
+ if (cells.length >= 6) {
4109
+ sessions.push({
4110
+ assignmentSlug: cells[0],
4111
+ agent: cells[1],
4112
+ sessionId: cells[2],
4113
+ started: cells[3],
4114
+ status: cells[4] || "active",
4115
+ path: cells[5],
4116
+ projectSlug
4117
+ });
4118
+ }
4119
+ }
4120
+ }
4121
+ return sessions;
4122
+ }
4123
+
4124
+ // src/dashboard/agent-sessions.ts
4125
+ function rowToSession(row) {
4126
+ return {
4127
+ sessionId: row.session_id,
4128
+ projectSlug: row.project_slug ?? null,
4129
+ assignmentSlug: row.assignment_slug ?? null,
4130
+ agent: row.agent,
4131
+ started: row.started,
4132
+ ended: row.ended ?? null,
4133
+ status: row.status,
4134
+ path: row.path ?? "",
4135
+ description: row.description ?? null,
4136
+ transcriptPath: row.transcript_path ?? null
4137
+ };
4138
+ }
4139
+ async function appendSession(_projectDir, session) {
4140
+ const db2 = getSessionDb();
4141
+ db2.prepare(`
4142
+ INSERT INTO sessions (session_id, project_slug, assignment_slug, agent, started, status, path, description, transcript_path)
4143
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
4144
+ ON CONFLICT(session_id) DO UPDATE SET
4145
+ project_slug = COALESCE(NULLIF(excluded.project_slug, ''), project_slug),
4146
+ assignment_slug = COALESCE(NULLIF(excluded.assignment_slug, ''), assignment_slug),
4147
+ agent = excluded.agent,
4148
+ status = CASE WHEN status IN ('completed','stopped') THEN status ELSE excluded.status END,
4149
+ path = COALESCE(NULLIF(excluded.path, ''), path),
4150
+ description = COALESCE(NULLIF(excluded.description, ''), description),
4151
+ transcript_path = COALESCE(NULLIF(excluded.transcript_path, ''), transcript_path),
4152
+ updated_at = datetime('now')
4153
+ `).run(
4154
+ session.sessionId,
4155
+ session.projectSlug ?? null,
4156
+ session.assignmentSlug ?? null,
4157
+ session.agent,
4158
+ session.started,
4159
+ session.status,
4160
+ session.path,
4161
+ session.description ?? null,
4162
+ session.transcriptPath ?? null
4163
+ );
4164
+ }
4165
+ async function updateSessionStatus(_projectDir, sessionId, status) {
4166
+ const db2 = getSessionDb();
4167
+ const isTerminal = status === "completed" || status === "stopped";
4168
+ const result = isTerminal ? db2.prepare(
4169
+ "UPDATE sessions SET status = ?, ended = datetime('now'), updated_at = datetime('now') WHERE session_id = ?"
4170
+ ).run(status, sessionId) : db2.prepare(
4171
+ "UPDATE sessions SET status = ?, updated_at = datetime('now') WHERE session_id = ?"
4172
+ ).run(status, sessionId);
4173
+ return result.changes > 0;
4174
+ }
4175
+ async function listAllSessions(_projectsDir) {
4176
+ const db2 = getSessionDb();
4177
+ const rows = db2.prepare("SELECT * FROM sessions ORDER BY started DESC").all();
4178
+ return rows.map(rowToSession);
4179
+ }
4180
+ async function listProjectSessions(_projectsDir, projectSlug, assignmentSlug) {
4181
+ const db2 = getSessionDb();
4182
+ if (assignmentSlug) {
4183
+ const rows2 = db2.prepare(
4184
+ "SELECT * FROM sessions WHERE project_slug = ? AND assignment_slug = ? ORDER BY started DESC"
4185
+ ).all(projectSlug, assignmentSlug);
4186
+ return rows2.map(rowToSession);
4187
+ }
4188
+ const rows = db2.prepare("SELECT * FROM sessions WHERE project_slug = ? ORDER BY started DESC").all(projectSlug);
4189
+ return rows.map(rowToSession);
4190
+ }
4191
+ async function deleteSessions(sessionIds) {
4192
+ if (sessionIds.length === 0) return 0;
4193
+ const db2 = getSessionDb();
4194
+ const placeholders = sessionIds.map(() => "?").join(", ");
4195
+ const result = db2.prepare(`DELETE FROM sessions WHERE session_id IN (${placeholders})`).run(...sessionIds);
4196
+ return result.changes;
4197
+ }
4198
+ var DONE_ASSIGNMENT_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "review"]);
4199
+ async function readAssignmentStatusFromPath(assignmentMdPath) {
4200
+ if (!await fileExists(assignmentMdPath)) return null;
4201
+ const raw = await readFile8(assignmentMdPath, "utf-8");
4202
+ const match = raw.match(/^status:\s*(.+)$/m);
4203
+ return match ? match[1].trim() : null;
4204
+ }
4205
+ async function readAssignmentStatus(projectDir, assignmentSlug) {
4206
+ return readAssignmentStatusFromPath(
4207
+ resolve10(projectDir, "assignments", assignmentSlug, "assignment.md")
4208
+ );
4209
+ }
4210
+ async function reconcileActiveSessions(projectsDir, assignmentsDir) {
4211
+ const db2 = getSessionDb();
4212
+ const activeSessions = db2.prepare("SELECT * FROM sessions WHERE status = 'active' AND assignment_slug IS NOT NULL").all();
4213
+ if (activeSessions.length === 0) return 0;
4214
+ const assignmentStatuses = /* @__PURE__ */ new Map();
4215
+ const seen = /* @__PURE__ */ new Set();
4216
+ for (const session of activeSessions) {
4217
+ const aslug = session.assignment_slug;
4218
+ if (!aslug) continue;
4219
+ const projectKey = session.project_slug ?? "__standalone__";
4220
+ const key = `${projectKey}/${aslug}`;
4221
+ if (seen.has(key)) continue;
4222
+ seen.add(key);
4223
+ if (session.project_slug) {
4224
+ const status = await readAssignmentStatus(
4225
+ resolve10(projectsDir, session.project_slug),
4226
+ aslug
4227
+ );
4228
+ if (status) assignmentStatuses.set(key, status);
4229
+ } else if (assignmentsDir) {
4230
+ const status = await readAssignmentStatusFromPath(
4231
+ resolve10(assignmentsDir, aslug, "assignment.md")
4232
+ );
4233
+ if (status) assignmentStatuses.set(key, status);
4234
+ }
4235
+ }
4236
+ let totalUpdated = 0;
4237
+ for (const session of activeSessions) {
4238
+ const projectKey = session.project_slug ?? "__standalone__";
4239
+ const key = `${projectKey}/${session.assignment_slug}`;
4240
+ const assignmentStatus = assignmentStatuses.get(key);
4241
+ if (!assignmentStatus || !DONE_ASSIGNMENT_STATUSES.has(assignmentStatus)) continue;
4242
+ const newStatus = assignmentStatus === "failed" ? "stopped" : "completed";
4243
+ await updateSessionStatus("", session.session_id, newStatus);
4244
+ totalUpdated++;
4245
+ }
4246
+ return totalUpdated;
4247
+ }
4248
+ async function listSessionsByAssignment(projectSlug, assignmentSlug) {
4249
+ const db2 = getSessionDb();
4250
+ const rows = projectSlug === null ? db2.prepare(
4251
+ "SELECT * FROM sessions WHERE assignment_slug = ? AND project_slug IS NULL ORDER BY started DESC"
4252
+ ).all(assignmentSlug) : db2.prepare(
4253
+ "SELECT * FROM sessions WHERE project_slug = ? AND assignment_slug = ? ORDER BY started DESC"
4254
+ ).all(projectSlug, assignmentSlug);
4255
+ return rows.map(rowToSession);
4256
+ }
4257
+
4258
+ // src/dashboard/watcher.ts
4259
+ import { watch } from "chokidar";
4260
+ import { relative, sep } from "path";
4261
+ function createWatcher(options) {
4262
+ const { projectsDir, assignmentsDir, serversDir: serversDir2, playbooksDir: playbooksDir2, todosDir: todosDir2, onMessage, debounceMs = 300 } = options;
4263
+ const pendingEvents = /* @__PURE__ */ new Map();
4264
+ const projectsWatcher = watch(projectsDir, {
4265
+ ignoreInitial: true,
4266
+ persistent: true,
4267
+ depth: 10,
4268
+ ignored: /(^|[\/\\])\../
4269
+ });
4270
+ function handleProjectChange(filePath) {
4271
+ const rel = relative(projectsDir, filePath);
4272
+ const parts = rel.split(sep);
4273
+ if (parts.length === 0) return;
4274
+ const projectSlug = parts[0];
4275
+ let assignmentSlug;
4276
+ if (parts.length >= 3 && parts[1] === "assignments") {
4277
+ assignmentSlug = parts[2];
4278
+ }
4279
+ const debounceKey = assignmentSlug ? `${projectSlug}/${assignmentSlug}` : projectSlug;
4280
+ const existing = pendingEvents.get(debounceKey);
4281
+ if (existing) clearTimeout(existing);
4282
+ const messageType = assignmentSlug ? "assignment-updated" : "project-updated";
4283
+ pendingEvents.set(
4284
+ debounceKey,
4285
+ setTimeout(() => {
4286
+ pendingEvents.delete(debounceKey);
4287
+ const message = {
4288
+ type: messageType,
4289
+ projectSlug,
4290
+ assignmentSlug,
4291
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4292
+ };
4293
+ onMessage(message);
4294
+ }, debounceMs)
4295
+ );
4296
+ }
4297
+ projectsWatcher.on("change", handleProjectChange);
4298
+ projectsWatcher.on("add", handleProjectChange);
4299
+ projectsWatcher.on("unlink", handleProjectChange);
4300
+ let standaloneWatcher = null;
4301
+ if (assignmentsDir) {
4302
+ let handleStandaloneChange2 = function(filePath) {
4303
+ const rel = relative(assignmentsDir, filePath);
4304
+ const parts = rel.split(sep);
4305
+ if (parts.length === 0) return;
4306
+ const assignmentId = parts[0];
4307
+ if (!assignmentId) return;
4308
+ const debounceKey = `__standalone__/${assignmentId}`;
4309
+ const existing = pendingEvents.get(debounceKey);
4310
+ if (existing) clearTimeout(existing);
4311
+ pendingEvents.set(
4312
+ debounceKey,
4313
+ setTimeout(() => {
4314
+ pendingEvents.delete(debounceKey);
4315
+ const message = {
4316
+ type: "assignment-updated",
4317
+ projectSlug: null,
4318
+ assignmentSlug: assignmentId,
4319
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4320
+ };
4321
+ onMessage(message);
4322
+ }, debounceMs)
4323
+ );
4324
+ };
4325
+ var handleStandaloneChange = handleStandaloneChange2;
4326
+ standaloneWatcher = watch(assignmentsDir, {
4327
+ ignoreInitial: true,
4328
+ persistent: true,
4329
+ depth: 5,
4330
+ ignored: /(^|[\/\\])\../
4331
+ });
4332
+ standaloneWatcher.on("change", handleStandaloneChange2);
4333
+ standaloneWatcher.on("add", handleStandaloneChange2);
4334
+ standaloneWatcher.on("unlink", handleStandaloneChange2);
4335
+ }
4336
+ let serversWatcher = null;
4337
+ if (serversDir2) {
4338
+ let handleServerChange2 = function() {
4339
+ const debounceKey = "__servers__";
4340
+ const existing = pendingEvents.get(debounceKey);
4341
+ if (existing) clearTimeout(existing);
4342
+ pendingEvents.set(
4343
+ debounceKey,
4344
+ setTimeout(() => {
4345
+ pendingEvents.delete(debounceKey);
4346
+ const message = {
4347
+ type: "servers-updated",
4348
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4349
+ };
4350
+ onMessage(message);
4351
+ }, debounceMs)
4352
+ );
4353
+ };
4354
+ var handleServerChange = handleServerChange2;
4355
+ serversWatcher = watch(serversDir2, {
4356
+ ignoreInitial: true,
4357
+ persistent: true,
4358
+ depth: 1,
4359
+ ignored: /(^|[\/\\])\../
4360
+ });
4361
+ serversWatcher.on("change", handleServerChange2);
4362
+ serversWatcher.on("add", handleServerChange2);
4363
+ serversWatcher.on("unlink", handleServerChange2);
4364
+ }
4365
+ let playbooksWatcher = null;
4366
+ if (playbooksDir2) {
4367
+ let handlePlaybookChange2 = function() {
4368
+ const debounceKey = "__playbooks__";
4369
+ const existing = pendingEvents.get(debounceKey);
4370
+ if (existing) clearTimeout(existing);
4371
+ pendingEvents.set(
4372
+ debounceKey,
4373
+ setTimeout(() => {
4374
+ pendingEvents.delete(debounceKey);
3313
4375
  const message = {
3314
4376
  type: "playbooks-updated",
3315
4377
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
@@ -3365,6 +4427,7 @@ function createWatcher(options) {
3365
4427
  });
3366
4428
  pendingEvents.clear();
3367
4429
  await projectsWatcher.close();
4430
+ if (standaloneWatcher) await standaloneWatcher.close();
3368
4431
  if (serversWatcher) await serversWatcher.close();
3369
4432
  if (playbooksWatcher) await playbooksWatcher.close();
3370
4433
  if (todosWatcher) await todosWatcher.close();
@@ -3379,8 +4442,8 @@ init_config2();
3379
4442
  // src/dashboard/api-write.ts
3380
4443
  init_lifecycle();
3381
4444
  import { Router } from "express";
3382
- import { resolve as resolve7 } from "path";
3383
- import { rm, readFile as readFile6 } from "fs/promises";
4445
+ import { resolve as resolve11 } from "path";
4446
+ import { rm, readFile as readFile9 } from "fs/promises";
3384
4447
 
3385
4448
  // src/utils/slug.ts
3386
4449
  function isValidSlug(slug) {
@@ -3445,6 +4508,7 @@ function toggleAcceptanceCriterion(content, index, checked) {
3445
4508
 
3446
4509
  // src/dashboard/api-write.ts
3447
4510
  init_api();
4511
+ init_assignment_resolver();
3448
4512
 
3449
4513
  // src/templates/index.ts
3450
4514
  init_config();
@@ -3608,12 +4672,60 @@ No handoffs recorded yet.
3608
4672
  `;
3609
4673
  }
3610
4674
 
3611
- // src/templates/decision-record.ts
3612
- function renderDecisionRecord(params) {
4675
+ // src/templates/progress.ts
4676
+ function renderProgress(params) {
3613
4677
  return `---
3614
- assignment: ${params.assignmentSlug}
4678
+ assignment: ${params.assignment}
4679
+ entryCount: 0
4680
+ generated: "${params.timestamp}"
3615
4681
  updated: "${params.timestamp}"
3616
- decisionCount: 0
4682
+ ---
4683
+
4684
+ # Progress
4685
+
4686
+ No progress yet.
4687
+ `;
4688
+ }
4689
+
4690
+ // src/templates/comments.ts
4691
+ function renderComments(params) {
4692
+ return `---
4693
+ assignment: ${params.assignment}
4694
+ entryCount: 0
4695
+ generated: "${params.timestamp}"
4696
+ updated: "${params.timestamp}"
4697
+ ---
4698
+
4699
+ # Comments
4700
+
4701
+ No comments yet.
4702
+ `;
4703
+ }
4704
+ function formatCommentEntry(comment) {
4705
+ const lines = [];
4706
+ lines.push(`## ${comment.id}`);
4707
+ lines.push("");
4708
+ lines.push(`**Recorded:** ${comment.timestamp}`);
4709
+ lines.push(`**Author:** ${comment.author}`);
4710
+ lines.push(`**Type:** ${comment.type}`);
4711
+ if (comment.replyTo) {
4712
+ lines.push(`**Reply to:** ${comment.replyTo}`);
4713
+ }
4714
+ if (comment.type === "question") {
4715
+ lines.push(`**Resolved:** ${comment.resolved ? "true" : "false"}`);
4716
+ }
4717
+ lines.push("");
4718
+ lines.push(comment.body.trim());
4719
+ lines.push("");
4720
+ return lines.join("\n");
4721
+ }
4722
+
4723
+ // src/templates/decision-record.ts
4724
+ function renderDecisionRecord(params) {
4725
+ return `---
4726
+ assignment: ${params.assignmentSlug}
4727
+ updated: "${params.timestamp}"
4728
+ decisionCount: 0
3617
4729
  ---
3618
4730
 
3619
4731
  # Decision Record
@@ -3753,6 +4865,8 @@ tags: []
3753
4865
  }
3754
4866
 
3755
4867
  // src/dashboard/api-write.ts
4868
+ init_lifecycle();
4869
+ init_parser();
3756
4870
  function extractFrontmatter3(content) {
3757
4871
  const trimmed = content.trimStart();
3758
4872
  if (!trimmed.startsWith("---\n") && !trimmed.startsWith("---\r\n")) {
@@ -3852,9 +4966,9 @@ async function readCurrentDocument(filePath) {
3852
4966
  if (!await fileExists(filePath)) {
3853
4967
  return null;
3854
4968
  }
3855
- return readFile6(filePath, "utf-8");
4969
+ return readFile9(filePath, "utf-8");
3856
4970
  }
3857
- function createWriteRouter(projectsDir) {
4971
+ function createWriteRouter(projectsDir, assignmentsDir) {
3858
4972
  const router = Router();
3859
4973
  router.get("/api/templates/project", (_req, res) => {
3860
4974
  const content = renderProject({
@@ -3982,26 +5096,26 @@ function createWriteRouter(projectsDir) {
3982
5096
  res.status(400).json({ error: `Invalid slug "${slug}". Must be lowercase and hyphen-separated.` });
3983
5097
  return;
3984
5098
  }
3985
- const projectDir = resolve7(projectsDir, slug);
5099
+ const projectDir = resolve11(projectsDir, slug);
3986
5100
  if (await fileExists(projectDir)) {
3987
5101
  res.status(409).json({ error: `Project "${slug}" already exists` });
3988
5102
  return;
3989
5103
  }
3990
5104
  const title = fields.title;
3991
5105
  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);
5106
+ await ensureDir(resolve11(projectDir, "assignments"));
5107
+ await ensureDir(resolve11(projectDir, "resources"));
5108
+ await ensureDir(resolve11(projectDir, "memories"));
5109
+ await writeFileForce(resolve11(projectDir, "project.md"), content);
3996
5110
  try {
3997
5111
  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 })]
5112
+ [resolve11(projectDir, "manifest.md"), renderManifest({ slug, timestamp })],
5113
+ [resolve11(projectDir, "_index-assignments.md"), renderIndexAssignments({ slug, title, timestamp })],
5114
+ [resolve11(projectDir, "_index-plans.md"), renderIndexPlans({ slug, title, timestamp })],
5115
+ [resolve11(projectDir, "_index-decisions.md"), renderIndexDecisions({ slug, title, timestamp })],
5116
+ [resolve11(projectDir, "_status.md"), renderStatus({ slug, title, timestamp })],
5117
+ [resolve11(projectDir, "resources", "_index.md"), renderResourcesIndex({ slug, title, timestamp })],
5118
+ [resolve11(projectDir, "memories", "_index.md"), renderMemoriesIndex({ slug, title, timestamp })]
4005
5119
  ];
4006
5120
  for (const [filePath, fileContent] of companions) {
4007
5121
  await writeFileForce(filePath, fileContent);
@@ -4022,8 +5136,8 @@ function createWriteRouter(projectsDir) {
4022
5136
  router.post("/api/projects/:slug/assignments", async (req, res) => {
4023
5137
  try {
4024
5138
  const projectSlug = getParam(req.params.slug);
4025
- const projectDir = resolve7(projectsDir, projectSlug);
4026
- const projectMdPath = resolve7(projectDir, "project.md");
5139
+ const projectDir = resolve11(projectsDir, projectSlug);
5140
+ const projectMdPath = resolve11(projectDir, "project.md");
4027
5141
  if (!await fileExists(projectMdPath)) {
4028
5142
  res.status(404).json({ error: `Project "${projectSlug}" not found` });
4029
5143
  return;
@@ -4053,7 +5167,7 @@ function createWriteRouter(projectsDir) {
4053
5167
  res.status(400).json({ error: `Invalid priority "${priority}". Must be low, medium, high, or critical.` });
4054
5168
  return;
4055
5169
  }
4056
- const assignmentDir = resolve7(projectDir, "assignments", assignmentSlug);
5170
+ const assignmentDir = resolve11(projectDir, "assignments", assignmentSlug);
4057
5171
  if (await fileExists(assignmentDir)) {
4058
5172
  res.status(409).json({
4059
5173
  error: `Assignment "${assignmentSlug}" already exists in project "${projectSlug}"`
@@ -4062,12 +5176,12 @@ function createWriteRouter(projectsDir) {
4062
5176
  }
4063
5177
  const timestamp = fields.created || nowTimestamp();
4064
5178
  await ensureDir(assignmentDir);
4065
- await writeFileForce(resolve7(assignmentDir, "assignment.md"), content);
5179
+ await writeFileForce(resolve11(assignmentDir, "assignment.md"), content);
4066
5180
  try {
4067
5181
  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 })]
5182
+ [resolve11(assignmentDir, "scratchpad.md"), renderScratchpad({ assignmentSlug, timestamp })],
5183
+ [resolve11(assignmentDir, "handoff.md"), renderHandoff({ assignmentSlug, timestamp })],
5184
+ [resolve11(assignmentDir, "decision-record.md"), renderDecisionRecord({ assignmentSlug, timestamp })]
4071
5185
  ];
4072
5186
  for (const [filePath, fileContent] of companions) {
4073
5187
  await writeFileForce(filePath, fileContent);
@@ -4088,7 +5202,7 @@ function createWriteRouter(projectsDir) {
4088
5202
  router.patch("/api/projects/:slug", async (req, res) => {
4089
5203
  try {
4090
5204
  const projectSlug = getParam(req.params.slug);
4091
- const projectPath = resolve7(projectsDir, projectSlug, "project.md");
5205
+ const projectPath = resolve11(projectsDir, projectSlug, "project.md");
4092
5206
  const currentContent = await readCurrentDocument(projectPath);
4093
5207
  if (!currentContent) {
4094
5208
  res.status(404).json({ error: `Project "${projectSlug}" not found` });
@@ -4121,7 +5235,7 @@ function createWriteRouter(projectsDir) {
4121
5235
  try {
4122
5236
  const projectSlug = getParam(req.params.slug);
4123
5237
  const assignmentSlug = getParam(req.params.aslug);
4124
- const assignmentPath = resolve7(
5238
+ const assignmentPath = resolve11(
4125
5239
  projectsDir,
4126
5240
  projectSlug,
4127
5241
  "assignments",
@@ -4164,7 +5278,7 @@ function createWriteRouter(projectsDir) {
4164
5278
  try {
4165
5279
  const projectSlug = getParam(req.params.slug);
4166
5280
  const assignmentSlug = getParam(req.params.aslug);
4167
- const assignmentPath = resolve7(
5281
+ const assignmentPath = resolve11(
4168
5282
  projectsDir,
4169
5283
  projectSlug,
4170
5284
  "assignments",
@@ -4200,7 +5314,7 @@ function createWriteRouter(projectsDir) {
4200
5314
  try {
4201
5315
  const projectSlug = getParam(req.params.slug);
4202
5316
  const assignmentSlug = getParam(req.params.aslug);
4203
- const planPath = resolve7(
5317
+ const planPath = resolve11(
4204
5318
  projectsDir,
4205
5319
  projectSlug,
4206
5320
  "assignments",
@@ -4238,7 +5352,7 @@ function createWriteRouter(projectsDir) {
4238
5352
  try {
4239
5353
  const projectSlug = getParam(req.params.slug);
4240
5354
  const assignmentSlug = getParam(req.params.aslug);
4241
- const scratchpadPath = resolve7(
5355
+ const scratchpadPath = resolve11(
4242
5356
  projectsDir,
4243
5357
  projectSlug,
4244
5358
  "assignments",
@@ -4276,7 +5390,7 @@ function createWriteRouter(projectsDir) {
4276
5390
  try {
4277
5391
  const projectSlug = getParam(req.params.slug);
4278
5392
  const assignmentSlug = getParam(req.params.aslug);
4279
- const handoffPath = resolve7(
5393
+ const handoffPath = resolve11(
4280
5394
  projectsDir,
4281
5395
  projectSlug,
4282
5396
  "assignments",
@@ -4314,7 +5428,7 @@ function createWriteRouter(projectsDir) {
4314
5428
  try {
4315
5429
  const projectSlug = getParam(req.params.slug);
4316
5430
  const assignmentSlug = getParam(req.params.aslug);
4317
- const decisionPath = resolve7(
5431
+ const decisionPath = resolve11(
4318
5432
  projectsDir,
4319
5433
  projectSlug,
4320
5434
  "assignments",
@@ -4348,10 +5462,121 @@ function createWriteRouter(projectsDir) {
4348
5462
  res.status(500).json({ error: `Failed to append decision entry: ${error.message}` });
4349
5463
  }
4350
5464
  });
5465
+ router.post("/api/projects/:slug/assignments/:aslug/comments", async (req, res) => {
5466
+ try {
5467
+ const projectSlug = getParam(req.params.slug);
5468
+ const assignmentSlug = getParam(req.params.aslug);
5469
+ const commentsPath = resolve11(
5470
+ projectsDir,
5471
+ projectSlug,
5472
+ "assignments",
5473
+ assignmentSlug,
5474
+ "comments.md"
5475
+ );
5476
+ const { body, author, type, replyTo } = req.body || {};
5477
+ if (!body || typeof body !== "string" || !body.trim()) {
5478
+ res.status(400).json({ error: "body is required" });
5479
+ return;
5480
+ }
5481
+ const commentType = type && ["question", "note", "feedback"].includes(type) ? type : "note";
5482
+ const timestamp = nowTimestamp();
5483
+ const entryAuthor = typeof author === "string" && author.trim() ? author.trim() : "human";
5484
+ let currentContent;
5485
+ let currentCount = 0;
5486
+ if (await fileExists(commentsPath)) {
5487
+ currentContent = await readFile9(commentsPath, "utf-8");
5488
+ const countMatch = currentContent.match(/^entryCount:\s*(\d+)/m);
5489
+ if (countMatch) currentCount = parseInt(countMatch[1], 10);
5490
+ } else {
5491
+ currentContent = renderComments({
5492
+ assignment: assignmentSlug,
5493
+ timestamp
5494
+ });
5495
+ }
5496
+ const comment = {
5497
+ id: generateId().split("-")[0],
5498
+ timestamp,
5499
+ author: entryAuthor,
5500
+ type: commentType,
5501
+ body,
5502
+ replyTo: typeof replyTo === "string" && replyTo.trim() ? replyTo.trim() : void 0,
5503
+ resolved: commentType === "question" ? false : void 0
5504
+ };
5505
+ const entry = formatCommentEntry(comment);
5506
+ let next = setTopLevelField(currentContent, "entryCount", String(currentCount + 1));
5507
+ next = setTopLevelField(next, "updated", `"${timestamp}"`);
5508
+ if (next.includes("No comments yet.")) {
5509
+ next = next.replace("No comments yet.", entry.trimEnd());
5510
+ } else {
5511
+ next = `${next.trimEnd()}
5512
+
5513
+ ${entry}`;
5514
+ }
5515
+ await writeFileForce(commentsPath, next);
5516
+ const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
5517
+ res.status(201).json({ assignment, comment: { id: comment.id } });
5518
+ } catch (error) {
5519
+ console.error("Error appending comment:", error);
5520
+ res.status(500).json({ error: `Failed to append comment: ${error.message}` });
5521
+ }
5522
+ });
5523
+ router.patch("/api/projects/:slug/assignments/:aslug/comments/:commentId/resolved", async (req, res) => {
5524
+ try {
5525
+ const projectSlug = getParam(req.params.slug);
5526
+ const assignmentSlug = getParam(req.params.aslug);
5527
+ const commentId = getParam(req.params.commentId);
5528
+ const commentsPath = resolve11(
5529
+ projectsDir,
5530
+ projectSlug,
5531
+ "assignments",
5532
+ assignmentSlug,
5533
+ "comments.md"
5534
+ );
5535
+ if (!await fileExists(commentsPath)) {
5536
+ res.status(404).json({ error: "Comments file not found" });
5537
+ return;
5538
+ }
5539
+ const { resolved } = req.body || {};
5540
+ if (typeof resolved !== "boolean") {
5541
+ res.status(400).json({ error: "resolved (boolean) is required" });
5542
+ return;
5543
+ }
5544
+ const content = await readFile9(commentsPath, "utf-8");
5545
+ const parsed = parseComments(content);
5546
+ const target = parsed.entries.find((e) => e.id === commentId);
5547
+ if (!target) {
5548
+ res.status(404).json({ error: `Comment ${commentId} not found` });
5549
+ return;
5550
+ }
5551
+ if (target.type !== "question") {
5552
+ res.status(400).json({ error: "Only questions can be resolved" });
5553
+ return;
5554
+ }
5555
+ const entryBlockRegex = new RegExp(
5556
+ `(^## ${commentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?)(\\*\\*Resolved:\\*\\*\\s*(?:true|false))`,
5557
+ "m"
5558
+ );
5559
+ const next = content.replace(
5560
+ entryBlockRegex,
5561
+ (_m, preamble) => `${preamble}**Resolved:** ${resolved ? "true" : "false"}`
5562
+ );
5563
+ if (next === content) {
5564
+ res.status(500).json({ error: "Failed to update resolved flag" });
5565
+ return;
5566
+ }
5567
+ const withUpdated = setTopLevelField(next, "updated", `"${nowTimestamp()}"`);
5568
+ await writeFileForce(commentsPath, withUpdated);
5569
+ const assignment = await getAssignmentDetail(projectsDir, projectSlug, assignmentSlug);
5570
+ res.json({ assignment });
5571
+ } catch (error) {
5572
+ console.error("Error toggling comment resolved flag:", error);
5573
+ res.status(500).json({ error: `Failed to toggle resolved: ${error.message}` });
5574
+ }
5575
+ });
4351
5576
  router.post("/api/projects/:slug/move-workspace", async (req, res) => {
4352
5577
  try {
4353
5578
  const projectSlug = getParam(req.params.slug);
4354
- const projectPath = resolve7(projectsDir, projectSlug, "project.md");
5579
+ const projectPath = resolve11(projectsDir, projectSlug, "project.md");
4355
5580
  if (!await fileExists(projectPath)) {
4356
5581
  res.status(404).json({ error: `Project "${projectSlug}" not found` });
4357
5582
  return;
@@ -4361,7 +5586,7 @@ function createWriteRouter(projectsDir) {
4361
5586
  res.status(400).json({ error: "workspace must be a non-empty string or null (for ungrouped)." });
4362
5587
  return;
4363
5588
  }
4364
- let content = await readFile6(projectPath, "utf-8");
5589
+ let content = await readFile9(projectPath, "utf-8");
4365
5590
  content = setTopLevelField(content, "workspace", workspace ?? null);
4366
5591
  content = setTopLevelField(content, "updated", nowTimestamp());
4367
5592
  await writeFileForce(projectPath, content);
@@ -4375,7 +5600,7 @@ function createWriteRouter(projectsDir) {
4375
5600
  router.post("/api/projects/:slug/status-override", async (req, res) => {
4376
5601
  try {
4377
5602
  const projectSlug = getParam(req.params.slug);
4378
- const projectPath = resolve7(projectsDir, projectSlug, "project.md");
5603
+ const projectPath = resolve11(projectsDir, projectSlug, "project.md");
4379
5604
  if (!await fileExists(projectPath)) {
4380
5605
  res.status(404).json({ error: `Project "${projectSlug}" not found` });
4381
5606
  return;
@@ -4387,7 +5612,7 @@ function createWriteRouter(projectsDir) {
4387
5612
  res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}, or null to clear.` });
4388
5613
  return;
4389
5614
  }
4390
- let content = await readFile6(projectPath, "utf-8");
5615
+ let content = await readFile9(projectPath, "utf-8");
4391
5616
  content = setTopLevelField(content, "statusOverride", status ?? null);
4392
5617
  content = setTopLevelField(content, "updated", nowTimestamp());
4393
5618
  await writeFileForce(projectPath, content);
@@ -4402,7 +5627,7 @@ function createWriteRouter(projectsDir) {
4402
5627
  try {
4403
5628
  const projectSlug = getParam(req.params.slug);
4404
5629
  const assignmentSlug = getParam(req.params.aslug);
4405
- const assignmentPath = resolve7(
5630
+ const assignmentPath = resolve11(
4406
5631
  projectsDir,
4407
5632
  projectSlug,
4408
5633
  "assignments",
@@ -4420,7 +5645,7 @@ function createWriteRouter(projectsDir) {
4420
5645
  res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}.` });
4421
5646
  return;
4422
5647
  }
4423
- let content = await readFile6(assignmentPath, "utf-8");
5648
+ let content = await readFile9(assignmentPath, "utf-8");
4424
5649
  content = setTopLevelField(content, "status", status);
4425
5650
  content = setTopLevelField(content, "updated", nowTimestamp());
4426
5651
  if (status !== "blocked") {
@@ -4445,8 +5670,8 @@ function createWriteRouter(projectsDir) {
4445
5670
  res.status(400).json({ error: `Unsupported transition command "${req.params.command}"` });
4446
5671
  return;
4447
5672
  }
4448
- const projectDir = resolve7(projectsDir, projectSlug);
4449
- const assignmentPath = resolve7(projectDir, "assignments", assignmentSlug, "assignment.md");
5673
+ const projectDir = resolve11(projectsDir, projectSlug);
5674
+ const assignmentPath = resolve11(projectDir, "assignments", assignmentSlug, "assignment.md");
4450
5675
  if (!await fileExists(assignmentPath)) {
4451
5676
  res.status(404).json({ error: "Assignment not found" });
4452
5677
  return;
@@ -4472,8 +5697,8 @@ function createWriteRouter(projectsDir) {
4472
5697
  try {
4473
5698
  const projectSlug = getParam(req.params.slug);
4474
5699
  const assignmentSlug = getParam(req.params.aslug);
4475
- const assignmentDir = resolve7(projectsDir, projectSlug, "assignments", assignmentSlug);
4476
- const assignmentPath = resolve7(assignmentDir, "assignment.md");
5700
+ const assignmentDir = resolve11(projectsDir, projectSlug, "assignments", assignmentSlug);
5701
+ const assignmentPath = resolve11(assignmentDir, "assignment.md");
4477
5702
  if (!await fileExists(assignmentPath)) {
4478
5703
  res.status(404).json({ error: `Assignment "${assignmentSlug}" not found in project "${projectSlug}"` });
4479
5704
  return;
@@ -4485,18 +5710,569 @@ function createWriteRouter(projectsDir) {
4485
5710
  res.status(500).json({ error: `Failed to delete assignment: ${error.message}` });
4486
5711
  }
4487
5712
  });
5713
+ router.post("/api/assignments", async (req, res) => {
5714
+ try {
5715
+ if (!assignmentsDir) {
5716
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
5717
+ return;
5718
+ }
5719
+ const { title, slug, priority, type } = req.body || {};
5720
+ if (!title || typeof title !== "string" || !title.trim()) {
5721
+ res.status(400).json({ error: "title is required" });
5722
+ return;
5723
+ }
5724
+ const { dependsOn } = req.body || {};
5725
+ if (Array.isArray(dependsOn) && dependsOn.length > 0) {
5726
+ res.status(400).json({ error: "Standalone assignments cannot declare dependsOn." });
5727
+ return;
5728
+ }
5729
+ const id = generateId();
5730
+ const assignmentDir = resolve11(assignmentsDir, id);
5731
+ if (await fileExists(assignmentDir)) {
5732
+ res.status(500).json({ error: "UUID collision \u2014 try again" });
5733
+ return;
5734
+ }
5735
+ const timestamp = nowTimestamp();
5736
+ const resolvedSlug = typeof slug === "string" && slug.trim() ? slug.trim() : slugifyLocal(title);
5737
+ const resolvedPriority = typeof priority === "string" && ["low", "medium", "high", "critical"].includes(priority) ? priority : "medium";
5738
+ await ensureDir(assignmentDir);
5739
+ const assignmentContent = renderAssignment({
5740
+ id,
5741
+ slug: resolvedSlug,
5742
+ title: title.trim(),
5743
+ timestamp,
5744
+ priority: resolvedPriority,
5745
+ dependsOn: [],
5746
+ links: [],
5747
+ project: null,
5748
+ type: typeof type === "string" ? type : void 0
5749
+ });
5750
+ await writeFileForce(resolve11(assignmentDir, "assignment.md"), assignmentContent);
5751
+ await writeFileForce(
5752
+ resolve11(assignmentDir, "scratchpad.md"),
5753
+ renderScratchpad({ assignmentSlug: id, timestamp })
5754
+ );
5755
+ await writeFileForce(
5756
+ resolve11(assignmentDir, "handoff.md"),
5757
+ renderHandoff({ assignmentSlug: id, timestamp })
5758
+ );
5759
+ await writeFileForce(
5760
+ resolve11(assignmentDir, "decision-record.md"),
5761
+ renderDecisionRecord({ assignmentSlug: id, timestamp })
5762
+ );
5763
+ await writeFileForce(
5764
+ resolve11(assignmentDir, "progress.md"),
5765
+ renderProgress({ assignment: id, timestamp })
5766
+ );
5767
+ await writeFileForce(
5768
+ resolve11(assignmentDir, "comments.md"),
5769
+ renderComments({ assignment: id, timestamp })
5770
+ );
5771
+ const detail = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
5772
+ res.status(201).json({ assignment: detail });
5773
+ } catch (error) {
5774
+ console.error("Error creating standalone assignment:", error);
5775
+ res.status(500).json({ error: `Failed to create standalone assignment: ${error.message}` });
5776
+ }
5777
+ });
5778
+ router.post("/api/assignments/:id/comments", async (req, res) => {
5779
+ try {
5780
+ if (!assignmentsDir) {
5781
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
5782
+ return;
5783
+ }
5784
+ const id = getParam(req.params.id);
5785
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
5786
+ if (!resolved) {
5787
+ res.status(404).json({ error: `Assignment "${id}" not found` });
5788
+ return;
5789
+ }
5790
+ await appendCommentTo(resolved.assignmentDir, resolved.standalone ? resolved.id : resolved.assignmentSlug, req, res, async () => {
5791
+ return resolved.standalone ? getAssignmentDetailById(projectsDir, assignmentsDir, id) : getAssignmentDetail(projectsDir, resolved.projectSlug, resolved.assignmentSlug);
5792
+ });
5793
+ } catch (error) {
5794
+ console.error("Error appending comment (by id):", error);
5795
+ res.status(500).json({ error: `Failed to append comment: ${error.message}` });
5796
+ }
5797
+ });
5798
+ router.patch("/api/assignments/:id/comments/:commentId/resolved", async (req, res) => {
5799
+ try {
5800
+ if (!assignmentsDir) {
5801
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
5802
+ return;
5803
+ }
5804
+ const id = getParam(req.params.id);
5805
+ const commentId = getParam(req.params.commentId);
5806
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
5807
+ if (!resolved) {
5808
+ res.status(404).json({ error: `Assignment "${id}" not found` });
5809
+ return;
5810
+ }
5811
+ await toggleCommentResolvedAt(resolved.assignmentDir, commentId, req, res, async () => {
5812
+ return resolved.standalone ? getAssignmentDetailById(projectsDir, assignmentsDir, id) : getAssignmentDetail(projectsDir, resolved.projectSlug, resolved.assignmentSlug);
5813
+ });
5814
+ } catch (error) {
5815
+ console.error("Error toggling comment resolved (by id):", error);
5816
+ res.status(500).json({ error: `Failed to toggle resolved: ${error.message}` });
5817
+ }
5818
+ });
5819
+ router.get("/api/assignments/:id/edit", async (req, res) => {
5820
+ if (!assignmentsDir) {
5821
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
5822
+ return;
5823
+ }
5824
+ const id = getParam(req.params.id);
5825
+ const doc = await getEditableDocumentById(projectsDir, assignmentsDir, "assignment", id);
5826
+ if (!doc) {
5827
+ res.status(404).json({ error: "Assignment not found" });
5828
+ return;
5829
+ }
5830
+ res.json(doc);
5831
+ });
5832
+ router.get("/api/assignments/:id/plan/edit", async (req, res) => {
5833
+ if (!assignmentsDir) {
5834
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
5835
+ return;
5836
+ }
5837
+ const id = getParam(req.params.id);
5838
+ const doc = await getEditableDocumentById(projectsDir, assignmentsDir, "plan", id);
5839
+ if (!doc) {
5840
+ res.status(404).json({ error: "Plan not found" });
5841
+ return;
5842
+ }
5843
+ res.json(doc);
5844
+ });
5845
+ router.get("/api/assignments/:id/scratchpad/edit", async (req, res) => {
5846
+ if (!assignmentsDir) {
5847
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
5848
+ return;
5849
+ }
5850
+ const id = getParam(req.params.id);
5851
+ const doc = await getEditableDocumentById(projectsDir, assignmentsDir, "scratchpad", id);
5852
+ if (!doc) {
5853
+ res.status(404).json({ error: "Scratchpad not found" });
5854
+ return;
5855
+ }
5856
+ res.json(doc);
5857
+ });
5858
+ router.get("/api/assignments/:id/handoff/edit", async (req, res) => {
5859
+ if (!assignmentsDir) {
5860
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
5861
+ return;
5862
+ }
5863
+ const id = getParam(req.params.id);
5864
+ const doc = await getEditableDocumentById(projectsDir, assignmentsDir, "handoff", id);
5865
+ if (!doc) {
5866
+ res.status(404).json({ error: "Handoff log not found" });
5867
+ return;
5868
+ }
5869
+ res.json(doc);
5870
+ });
5871
+ router.get("/api/assignments/:id/decision-record/edit", async (req, res) => {
5872
+ if (!assignmentsDir) {
5873
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
5874
+ return;
5875
+ }
5876
+ const id = getParam(req.params.id);
5877
+ const doc = await getEditableDocumentById(projectsDir, assignmentsDir, "decision-record", id);
5878
+ if (!doc) {
5879
+ res.status(404).json({ error: "Decision record not found" });
5880
+ return;
5881
+ }
5882
+ res.json(doc);
5883
+ });
5884
+ router.patch("/api/assignments/:id", async (req, res) => {
5885
+ try {
5886
+ if (!assignmentsDir) {
5887
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
5888
+ return;
5889
+ }
5890
+ const id = getParam(req.params.id);
5891
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
5892
+ if (!resolved) {
5893
+ res.status(404).json({ error: `Assignment "${id}" not found` });
5894
+ return;
5895
+ }
5896
+ const assignmentPath = resolve11(resolved.assignmentDir, "assignment.md");
5897
+ const currentContent = await readCurrentDocument(assignmentPath);
5898
+ if (!currentContent) {
5899
+ res.status(404).json({ error: "Assignment not found" });
5900
+ return;
5901
+ }
5902
+ const nextContentRaw = requireContent(req, res);
5903
+ if (!nextContentRaw) return;
5904
+ const current = parseAssignmentFull(currentContent);
5905
+ const next = parseAssignmentFull(nextContentRaw);
5906
+ if (!next.title) {
5907
+ res.status(400).json({ error: "Assignment content must include a title." });
5908
+ return;
5909
+ }
5910
+ let nextContent = nextContentRaw;
5911
+ if (current.id) nextContent = setTopLevelField(nextContent, "id", current.id);
5912
+ nextContent = setTopLevelField(nextContent, "project", null);
5913
+ if (current.slug) nextContent = setTopLevelField(nextContent, "slug", current.slug);
5914
+ if (next.status !== current.status && current.status === "blocked" && next.status !== "blocked") {
5915
+ nextContent = setTopLevelField(nextContent, "blockedReason", null);
5916
+ }
5917
+ nextContent = setTopLevelField(nextContent, "updated", nowTimestamp());
5918
+ await writeFileForce(assignmentPath, nextContent);
5919
+ const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
5920
+ res.json({ assignment, content: nextContent });
5921
+ } catch (error) {
5922
+ console.error("Error updating standalone assignment:", error);
5923
+ res.status(500).json({ error: `Failed to update assignment: ${error.message}` });
5924
+ }
5925
+ });
5926
+ router.patch("/api/assignments/:id/plan", async (req, res) => {
5927
+ try {
5928
+ if (!assignmentsDir) {
5929
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
5930
+ return;
5931
+ }
5932
+ const id = getParam(req.params.id);
5933
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
5934
+ if (!resolved) {
5935
+ res.status(404).json({ error: `Assignment "${id}" not found` });
5936
+ return;
5937
+ }
5938
+ const planPath = resolve11(resolved.assignmentDir, "plan.md");
5939
+ const currentContent = await readCurrentDocument(planPath);
5940
+ if (!currentContent) {
5941
+ res.status(404).json({ error: "Plan not found" });
5942
+ return;
5943
+ }
5944
+ const nextContentRaw = requireContent(req, res);
5945
+ if (!nextContentRaw) return;
5946
+ const parsed = parsePlan(nextContentRaw);
5947
+ if (!parsed.assignment) {
5948
+ res.status(400).json({ error: "Plan content must include the assignment field." });
5949
+ return;
5950
+ }
5951
+ const nextContent = setTopLevelField(nextContentRaw, "updated", nowTimestamp());
5952
+ await writeFileForce(planPath, nextContent);
5953
+ const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
5954
+ res.json({ assignment, content: nextContent });
5955
+ } catch (error) {
5956
+ console.error("Error updating standalone plan:", error);
5957
+ res.status(500).json({ error: `Failed to update plan: ${error.message}` });
5958
+ }
5959
+ });
5960
+ router.patch("/api/assignments/:id/scratchpad", async (req, res) => {
5961
+ try {
5962
+ if (!assignmentsDir) {
5963
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
5964
+ return;
5965
+ }
5966
+ const id = getParam(req.params.id);
5967
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
5968
+ if (!resolved) {
5969
+ res.status(404).json({ error: `Assignment "${id}" not found` });
5970
+ return;
5971
+ }
5972
+ const scratchpadPath = resolve11(resolved.assignmentDir, "scratchpad.md");
5973
+ const currentContent = await readCurrentDocument(scratchpadPath);
5974
+ if (!currentContent) {
5975
+ res.status(404).json({ error: "Scratchpad not found" });
5976
+ return;
5977
+ }
5978
+ const nextContentRaw = requireContent(req, res);
5979
+ if (!nextContentRaw) return;
5980
+ const parsed = parseScratchpad(nextContentRaw);
5981
+ if (!parsed.assignment) {
5982
+ res.status(400).json({ error: "Scratchpad content must include the assignment field." });
5983
+ return;
5984
+ }
5985
+ const nextContent = setTopLevelField(nextContentRaw, "updated", nowTimestamp());
5986
+ await writeFileForce(scratchpadPath, nextContent);
5987
+ const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
5988
+ res.json({ assignment, content: nextContent });
5989
+ } catch (error) {
5990
+ console.error("Error updating standalone scratchpad:", error);
5991
+ res.status(500).json({ error: `Failed to update scratchpad: ${error.message}` });
5992
+ }
5993
+ });
5994
+ router.post("/api/assignments/:id/handoff/entries", async (req, res) => {
5995
+ try {
5996
+ if (!assignmentsDir) {
5997
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
5998
+ return;
5999
+ }
6000
+ const id = getParam(req.params.id);
6001
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
6002
+ if (!resolved) {
6003
+ res.status(404).json({ error: `Assignment "${id}" not found` });
6004
+ return;
6005
+ }
6006
+ const handoffPath = resolve11(resolved.assignmentDir, "handoff.md");
6007
+ const currentContent = await readCurrentDocument(handoffPath);
6008
+ if (!currentContent) {
6009
+ res.status(404).json({ error: "Handoff log not found" });
6010
+ return;
6011
+ }
6012
+ const { title, body } = req.body || {};
6013
+ if (!body || typeof body !== "string" || !body.trim()) {
6014
+ res.status(400).json({ error: "body is required" });
6015
+ return;
6016
+ }
6017
+ const parsed = parseHandoff(currentContent);
6018
+ const nextContent = appendLogEntry(
6019
+ currentContent,
6020
+ "handoffCount",
6021
+ parsed.handoffCount + 1,
6022
+ title && typeof title === "string" && title.trim() ? title.trim() : `Handoff ${parsed.handoffCount + 1}`,
6023
+ body,
6024
+ "No handoffs recorded yet."
6025
+ );
6026
+ await writeFileForce(handoffPath, nextContent);
6027
+ const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
6028
+ res.status(201).json({ assignment, content: nextContent });
6029
+ } catch (error) {
6030
+ console.error("Error appending standalone handoff entry:", error);
6031
+ res.status(500).json({ error: `Failed to append handoff entry: ${error.message}` });
6032
+ }
6033
+ });
6034
+ router.post("/api/assignments/:id/decision-record/entries", async (req, res) => {
6035
+ try {
6036
+ if (!assignmentsDir) {
6037
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
6038
+ return;
6039
+ }
6040
+ const id = getParam(req.params.id);
6041
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
6042
+ if (!resolved) {
6043
+ res.status(404).json({ error: `Assignment "${id}" not found` });
6044
+ return;
6045
+ }
6046
+ const decisionPath = resolve11(resolved.assignmentDir, "decision-record.md");
6047
+ const currentContent = await readCurrentDocument(decisionPath);
6048
+ if (!currentContent) {
6049
+ res.status(404).json({ error: "Decision record not found" });
6050
+ return;
6051
+ }
6052
+ const { title, body } = req.body || {};
6053
+ if (!body || typeof body !== "string" || !body.trim()) {
6054
+ res.status(400).json({ error: "body is required" });
6055
+ return;
6056
+ }
6057
+ const parsed = parseDecisionRecord(currentContent);
6058
+ const nextContent = appendLogEntry(
6059
+ currentContent,
6060
+ "decisionCount",
6061
+ parsed.decisionCount + 1,
6062
+ title && typeof title === "string" && title.trim() ? title.trim() : `Decision ${parsed.decisionCount + 1}`,
6063
+ body,
6064
+ "No decisions recorded yet."
6065
+ );
6066
+ await writeFileForce(decisionPath, nextContent);
6067
+ const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
6068
+ res.status(201).json({ assignment, content: nextContent });
6069
+ } catch (error) {
6070
+ console.error("Error appending standalone decision entry:", error);
6071
+ res.status(500).json({ error: `Failed to append decision entry: ${error.message}` });
6072
+ }
6073
+ });
6074
+ router.post("/api/assignments/:id/status-override", async (req, res) => {
6075
+ try {
6076
+ if (!assignmentsDir) {
6077
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
6078
+ return;
6079
+ }
6080
+ const id = getParam(req.params.id);
6081
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
6082
+ if (!resolved) {
6083
+ res.status(404).json({ error: `Assignment "${id}" not found` });
6084
+ return;
6085
+ }
6086
+ const assignmentPath = resolve11(resolved.assignmentDir, "assignment.md");
6087
+ if (!await fileExists(assignmentPath)) {
6088
+ res.status(404).json({ error: "Assignment not found" });
6089
+ return;
6090
+ }
6091
+ const { status } = req.body || {};
6092
+ const config = await getStatusConfig();
6093
+ const validStatuses = config.statuses.map((s) => s.id);
6094
+ if (typeof status !== "string" || !validStatuses.includes(status)) {
6095
+ res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}.` });
6096
+ return;
6097
+ }
6098
+ let content = await readFile9(assignmentPath, "utf-8");
6099
+ content = setTopLevelField(content, "status", status);
6100
+ content = setTopLevelField(content, "updated", nowTimestamp());
6101
+ if (status !== "blocked") {
6102
+ content = setTopLevelField(content, "blockedReason", null);
6103
+ }
6104
+ await writeFileForce(assignmentPath, content);
6105
+ const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
6106
+ res.json({ assignment });
6107
+ } catch (error) {
6108
+ console.error("Error overriding standalone status:", error);
6109
+ res.status(500).json({ error: `Failed to override status: ${error.message}` });
6110
+ }
6111
+ });
6112
+ router.patch("/api/assignments/:id/acceptance-criteria/:index", async (req, res) => {
6113
+ try {
6114
+ if (!assignmentsDir) {
6115
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
6116
+ return;
6117
+ }
6118
+ const id = getParam(req.params.id);
6119
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
6120
+ if (!resolved) {
6121
+ res.status(404).json({ error: `Assignment "${id}" not found` });
6122
+ return;
6123
+ }
6124
+ const assignmentPath = resolve11(resolved.assignmentDir, "assignment.md");
6125
+ const currentContent = await readCurrentDocument(assignmentPath);
6126
+ if (!currentContent) {
6127
+ res.status(404).json({ error: "Assignment not found" });
6128
+ return;
6129
+ }
6130
+ const { checked } = req.body || {};
6131
+ if (typeof checked !== "boolean") {
6132
+ res.status(400).json({ error: "checked must be a boolean" });
6133
+ return;
6134
+ }
6135
+ const index = Number.parseInt(getParam(req.params.index), 10);
6136
+ const result = toggleAcceptanceCriterion(currentContent, index, checked);
6137
+ if ("error" in result) {
6138
+ res.status(400).json({ error: result.error });
6139
+ return;
6140
+ }
6141
+ const nextContent = setTopLevelField(result.content, "updated", nowTimestamp());
6142
+ await writeFileForce(assignmentPath, nextContent);
6143
+ const assignment = await getAssignmentDetailById(projectsDir, assignmentsDir, id);
6144
+ res.json({ assignment, content: nextContent });
6145
+ } catch (error) {
6146
+ console.error("Error toggling standalone acceptance criterion:", error);
6147
+ res.status(500).json({ error: `Failed to toggle acceptance criterion: ${error.message}` });
6148
+ }
6149
+ });
6150
+ router.post("/api/assignments/:id/transitions/:command", async (req, res) => {
6151
+ try {
6152
+ if (!assignmentsDir) {
6153
+ res.status(501).json({ error: "Standalone assignments not configured on this server" });
6154
+ return;
6155
+ }
6156
+ const id = getParam(req.params.id);
6157
+ const command = getParam(req.params.command);
6158
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, id);
6159
+ if (!resolved) {
6160
+ res.status(404).json({ error: `Assignment "${id}" not found` });
6161
+ return;
6162
+ }
6163
+ const { reason } = req.body || {};
6164
+ const transitionResult = await executeTransitionByDir(
6165
+ resolved.assignmentDir,
6166
+ command,
6167
+ {
6168
+ standalone: resolved.standalone,
6169
+ reason: typeof reason === "string" ? reason : void 0
6170
+ }
6171
+ );
6172
+ if (!transitionResult.success) {
6173
+ res.status(400).json({ error: transitionResult.message, fromStatus: transitionResult.fromStatus });
6174
+ return;
6175
+ }
6176
+ const detail = resolved.standalone ? await getAssignmentDetailById(projectsDir, assignmentsDir, id) : await getAssignmentDetail(projectsDir, resolved.projectSlug, resolved.assignmentSlug);
6177
+ res.json({ assignment: detail, warnings: transitionResult.warnings ?? [] });
6178
+ } catch (error) {
6179
+ console.error("Error transitioning by id:", error);
6180
+ res.status(500).json({ error: `Failed to transition: ${error.message}` });
6181
+ }
6182
+ });
4488
6183
  return router;
4489
6184
  }
6185
+ function slugifyLocal(input) {
6186
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "untitled";
6187
+ }
6188
+ async function appendCommentTo(assignmentDir, assignmentRef, req, res, reloadDetail) {
6189
+ const commentsPath = resolve11(assignmentDir, "comments.md");
6190
+ const { body, author, type, replyTo } = req.body || {};
6191
+ if (!body || typeof body !== "string" || !body.trim()) {
6192
+ res.status(400).json({ error: "body is required" });
6193
+ return;
6194
+ }
6195
+ const commentType = type && ["question", "note", "feedback"].includes(type) ? type : "note";
6196
+ const timestamp = nowTimestamp();
6197
+ const entryAuthor = typeof author === "string" && author.trim() ? author.trim() : "human";
6198
+ let currentContent;
6199
+ let currentCount = 0;
6200
+ if (await fileExists(commentsPath)) {
6201
+ currentContent = await readFile9(commentsPath, "utf-8");
6202
+ const countMatch = currentContent.match(/^entryCount:\s*(\d+)/m);
6203
+ if (countMatch) currentCount = parseInt(countMatch[1], 10);
6204
+ } else {
6205
+ currentContent = renderComments({ assignment: assignmentRef, timestamp });
6206
+ }
6207
+ const comment = {
6208
+ id: generateId().split("-")[0],
6209
+ timestamp,
6210
+ author: entryAuthor,
6211
+ type: commentType,
6212
+ body,
6213
+ replyTo: typeof replyTo === "string" && replyTo.trim() ? replyTo.trim() : void 0,
6214
+ resolved: commentType === "question" ? false : void 0
6215
+ };
6216
+ const entry = formatCommentEntry(comment);
6217
+ let next = setTopLevelField(currentContent, "entryCount", String(currentCount + 1));
6218
+ next = setTopLevelField(next, "updated", `"${timestamp}"`);
6219
+ if (next.includes("No comments yet.")) {
6220
+ next = next.replace("No comments yet.", entry.trimEnd());
6221
+ } else {
6222
+ next = `${next.trimEnd()}
6223
+
6224
+ ${entry}`;
6225
+ }
6226
+ await writeFileForce(commentsPath, next);
6227
+ const assignment = await reloadDetail();
6228
+ res.status(201).json({ assignment, comment: { id: comment.id } });
6229
+ }
6230
+ async function toggleCommentResolvedAt(assignmentDir, commentId, req, res, reloadDetail) {
6231
+ const commentsPath = resolve11(assignmentDir, "comments.md");
6232
+ if (!await fileExists(commentsPath)) {
6233
+ res.status(404).json({ error: "Comments file not found" });
6234
+ return;
6235
+ }
6236
+ const { resolved: desired } = req.body || {};
6237
+ if (typeof desired !== "boolean") {
6238
+ res.status(400).json({ error: "resolved (boolean) is required" });
6239
+ return;
6240
+ }
6241
+ const content = await readFile9(commentsPath, "utf-8");
6242
+ const parsed = parseComments(content);
6243
+ const target = parsed.entries.find((e) => e.id === commentId);
6244
+ if (!target) {
6245
+ res.status(404).json({ error: `Comment ${commentId} not found` });
6246
+ return;
6247
+ }
6248
+ if (target.type !== "question") {
6249
+ res.status(400).json({ error: "Only questions can be resolved" });
6250
+ return;
6251
+ }
6252
+ const entryBlockRegex = new RegExp(
6253
+ `(^## ${commentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?)(\\*\\*Resolved:\\*\\*\\s*(?:true|false))`,
6254
+ "m"
6255
+ );
6256
+ const next = content.replace(entryBlockRegex, (_m, preamble) => `${preamble}**Resolved:** ${desired ? "true" : "false"}`);
6257
+ if (next === content) {
6258
+ res.status(500).json({ error: "Failed to update resolved flag" });
6259
+ return;
6260
+ }
6261
+ const withUpdated = setTopLevelField(next, "updated", `"${nowTimestamp()}"`);
6262
+ await writeFileForce(commentsPath, withUpdated);
6263
+ const assignment = await reloadDetail();
6264
+ res.json({ assignment });
6265
+ }
4490
6266
 
4491
6267
  // src/dashboard/api-servers.ts
4492
6268
  init_servers();
4493
6269
  init_scanner();
4494
6270
  import { Router as Router2 } from "express";
4495
- function createServersRouter(serversDir2, projectsDir) {
6271
+ function createServersRouter(serversDir2, projectsDir, assignmentsDir) {
4496
6272
  const router = Router2();
4497
6273
  router.get("/", async (_req, res) => {
4498
6274
  try {
4499
- const result = await scanAllSessions(serversDir2, projectsDir);
6275
+ const result = await scanAllSessions(serversDir2, projectsDir, { assignmentsDir });
4500
6276
  res.json(result);
4501
6277
  } catch (error) {
4502
6278
  res.status(500).json({ error: error instanceof Error ? error.message : "Scan failed" });
@@ -4504,7 +6280,7 @@ function createServersRouter(serversDir2, projectsDir) {
4504
6280
  });
4505
6281
  router.get("/:name", async (req, res) => {
4506
6282
  try {
4507
- const session = await scanSingleSession(serversDir2, projectsDir, req.params.name);
6283
+ const session = await scanSingleSession(serversDir2, projectsDir, req.params.name, { assignmentsDir });
4508
6284
  if (!session) {
4509
6285
  res.status(404).json({ error: "Session not found" });
4510
6286
  return;
@@ -4555,7 +6331,7 @@ function createServersRouter(serversDir2, projectsDir) {
4555
6331
  await updateLastRefreshed(serversDir2, name);
4556
6332
  }
4557
6333
  clearScanCache();
4558
- const result = await scanAllSessions(serversDir2, projectsDir, { bypassCache: true });
6334
+ const result = await scanAllSessions(serversDir2, projectsDir, { bypassCache: true, assignmentsDir });
4559
6335
  res.json(result);
4560
6336
  } catch (error) {
4561
6337
  res.status(500).json({ error: error instanceof Error ? error.message : "Refresh failed" });
@@ -4570,7 +6346,7 @@ function createServersRouter(serversDir2, projectsDir) {
4570
6346
  }
4571
6347
  await updateLastRefreshed(serversDir2, req.params.name);
4572
6348
  clearScanCache();
4573
- const session = await scanSingleSession(serversDir2, projectsDir, req.params.name);
6349
+ const session = await scanSingleSession(serversDir2, projectsDir, req.params.name, { assignmentsDir });
4574
6350
  res.json(session);
4575
6351
  } catch (error) {
4576
6352
  res.status(500).json({ error: error instanceof Error ? error.message : "Refresh failed" });
@@ -4607,266 +6383,13 @@ function createServersRouter(serversDir2, projectsDir) {
4607
6383
 
4608
6384
  // src/dashboard/api-agent-sessions.ts
4609
6385
  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
6386
+ import { resolve as resolve12 } from "path";
4864
6387
  init_fs();
4865
- function createAgentSessionsRouter(projectsDir, broadcast) {
6388
+ function createAgentSessionsRouter(projectsDir, broadcast, assignmentsDir) {
4866
6389
  const router = Router3();
4867
6390
  router.get("/", async (_req, res) => {
4868
6391
  try {
4869
- await reconcileActiveSessions(projectsDir);
6392
+ await reconcileActiveSessions(projectsDir, assignmentsDir);
4870
6393
  const sessions = await listAllSessions(projectsDir);
4871
6394
  res.json({ sessions, generatedAt: (/* @__PURE__ */ new Date()).toISOString() });
4872
6395
  } catch (error) {
@@ -4877,12 +6400,12 @@ function createAgentSessionsRouter(projectsDir, broadcast) {
4877
6400
  try {
4878
6401
  const { projectSlug } = req.params;
4879
6402
  const assignment = req.query.assignment;
4880
- const projectDir = resolve10(projectsDir, projectSlug);
6403
+ const projectDir = resolve12(projectsDir, projectSlug);
4881
6404
  if (!await fileExists(projectDir)) {
4882
6405
  res.status(404).json({ error: `Project "${projectSlug}" not found` });
4883
6406
  return;
4884
6407
  }
4885
- await reconcileActiveSessions(projectsDir);
6408
+ await reconcileActiveSessions(projectsDir, assignmentsDir);
4886
6409
  const sessions = await listProjectSessions(projectsDir, projectSlug, assignment);
4887
6410
  res.json({ sessions, generatedAt: (/* @__PURE__ */ new Date()).toISOString() });
4888
6411
  } catch (error) {
@@ -4891,32 +6414,38 @@ function createAgentSessionsRouter(projectsDir, broadcast) {
4891
6414
  });
4892
6415
  router.post("/", async (req, res) => {
4893
6416
  try {
4894
- const { projectSlug, assignmentSlug, agent, sessionId, path, description } = req.body;
6417
+ const { projectSlug, assignmentSlug, agent, sessionId, path, description, transcriptPath } = req.body;
4895
6418
  if (!agent) {
4896
6419
  res.status(400).json({ error: "agent is required" });
4897
6420
  return;
4898
6421
  }
6422
+ if (!sessionId) {
6423
+ res.status(400).json({
6424
+ error: "sessionId is required. Pass the real agent-generated session id \u2014 do not synthesize one."
6425
+ });
6426
+ return;
6427
+ }
4899
6428
  if (projectSlug) {
4900
- const projectDir = resolve10(projectsDir, projectSlug);
6429
+ const projectDir = resolve12(projectsDir, projectSlug);
4901
6430
  if (!await fileExists(projectDir)) {
4902
6431
  res.status(404).json({ error: `Project "${projectSlug}" not found` });
4903
6432
  return;
4904
6433
  }
4905
6434
  }
4906
- const id = sessionId || randomUUID2();
4907
6435
  const session = {
4908
6436
  projectSlug: projectSlug || null,
4909
6437
  assignmentSlug: assignmentSlug || null,
4910
6438
  agent,
4911
- sessionId: id,
6439
+ sessionId,
4912
6440
  started: (/* @__PURE__ */ new Date()).toISOString(),
4913
6441
  status: "active",
4914
6442
  path: path || "",
4915
- description: description || null
6443
+ description: description || null,
6444
+ transcriptPath: transcriptPath || null
4916
6445
  };
4917
6446
  await appendSession("", session);
4918
6447
  broadcast?.({ type: "agent-sessions-updated", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
4919
- res.status(201).json({ sessionId: id });
6448
+ res.status(201).json({ sessionId });
4920
6449
  } catch (error) {
4921
6450
  res.status(500).json({ error: error instanceof Error ? error.message : "Registration failed" });
4922
6451
  }
@@ -4965,8 +6494,8 @@ function createAgentSessionsRouter(projectsDir, broadcast) {
4965
6494
  init_api();
4966
6495
  init_parser();
4967
6496
  import { Router as Router4 } from "express";
4968
- import { resolve as resolve12 } from "path";
4969
- import { readFile as readFile9, unlink as unlink2 } from "fs/promises";
6497
+ import { resolve as resolve14 } from "path";
6498
+ import { readFile as readFile11, unlink as unlink2 } from "fs/promises";
4970
6499
  init_timestamp();
4971
6500
  init_fs();
4972
6501
 
@@ -4974,15 +6503,15 @@ init_fs();
4974
6503
  init_fs();
4975
6504
  init_parser();
4976
6505
  init_timestamp();
4977
- import { resolve as resolve11 } from "path";
4978
- import { readdir as readdir5, readFile as readFile8 } from "fs/promises";
6506
+ import { resolve as resolve13 } from "path";
6507
+ import { readdir as readdir7, readFile as readFile10 } from "fs/promises";
4979
6508
  async function rebuildPlaybookManifest(playbooksDir2) {
4980
6509
  if (!await fileExists(playbooksDir2)) return;
4981
- const entries = await readdir5(playbooksDir2, { withFileTypes: true });
6510
+ const entries = await readdir7(playbooksDir2, { withFileTypes: true });
4982
6511
  const rows = [];
4983
6512
  for (const entry of entries) {
4984
6513
  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");
6514
+ const raw = await readFile10(resolve13(playbooksDir2, entry.name), "utf-8");
4986
6515
  const parsed = parsePlaybook(raw);
4987
6516
  const slug = parsed.slug || entry.name.replace(/\.md$/, "");
4988
6517
  rows.push({
@@ -5012,7 +6541,7 @@ async function rebuildPlaybookManifest(playbooksDir2) {
5012
6541
  }
5013
6542
  }
5014
6543
  lines.push("");
5015
- await writeFileForce(resolve11(playbooksDir2, "manifest.md"), lines.join("\n"));
6544
+ await writeFileForce(resolve13(playbooksDir2, "manifest.md"), lines.join("\n"));
5016
6545
  }
5017
6546
 
5018
6547
  // src/dashboard/api-playbooks.ts
@@ -5053,12 +6582,12 @@ function createPlaybooksRouter(playbooksDir2) {
5053
6582
  });
5054
6583
  router.get("/:slug/edit", async (req, res) => {
5055
6584
  try {
5056
- const filePath = resolve12(playbooksDir2, `${req.params.slug}.md`);
6585
+ const filePath = resolve14(playbooksDir2, `${req.params.slug}.md`);
5057
6586
  if (!await fileExists(filePath)) {
5058
6587
  res.status(404).json({ error: `Playbook "${req.params.slug}" not found` });
5059
6588
  return;
5060
6589
  }
5061
- const content = await readFile9(filePath, "utf-8");
6590
+ const content = await readFile11(filePath, "utf-8");
5062
6591
  res.json({
5063
6592
  documentType: "playbook",
5064
6593
  title: `Edit Playbook: ${req.params.slug}`,
@@ -5083,7 +6612,7 @@ function createPlaybooksRouter(playbooksDir2) {
5083
6612
  return;
5084
6613
  }
5085
6614
  await ensureDir(playbooksDir2);
5086
- const filePath = resolve12(playbooksDir2, `${slug}.md`);
6615
+ const filePath = resolve14(playbooksDir2, `${slug}.md`);
5087
6616
  if (await fileExists(filePath)) {
5088
6617
  res.status(409).json({ error: `Playbook "${slug}" already exists` });
5089
6618
  return;
@@ -5102,7 +6631,7 @@ function createPlaybooksRouter(playbooksDir2) {
5102
6631
  res.status(400).json({ error: "content is required" });
5103
6632
  return;
5104
6633
  }
5105
- const filePath = resolve12(playbooksDir2, `${req.params.slug}.md`);
6634
+ const filePath = resolve14(playbooksDir2, `${req.params.slug}.md`);
5106
6635
  if (!await fileExists(filePath)) {
5107
6636
  res.status(404).json({ error: `Playbook "${req.params.slug}" not found` });
5108
6637
  return;
@@ -5120,7 +6649,7 @@ function createPlaybooksRouter(playbooksDir2) {
5120
6649
  res.status(403).json({ error: "The playbook manifest cannot be deleted" });
5121
6650
  return;
5122
6651
  }
5123
- const filePath = resolve12(playbooksDir2, `${req.params.slug}.md`);
6652
+ const filePath = resolve14(playbooksDir2, `${req.params.slug}.md`);
5124
6653
  if (!await fileExists(filePath)) {
5125
6654
  res.status(404).json({ error: `Playbook "${req.params.slug}" not found` });
5126
6655
  return;
@@ -5135,11 +6664,14 @@ function createPlaybooksRouter(playbooksDir2) {
5135
6664
  return router;
5136
6665
  }
5137
6666
 
6667
+ // src/dashboard/server.ts
6668
+ init_fs_migration();
6669
+
5138
6670
  // src/dashboard/api-todos.ts
5139
6671
  init_parser2();
5140
6672
  init_fs();
5141
6673
  import { Router as Router5 } from "express";
5142
- import { readdir as readdir6 } from "fs/promises";
6674
+ import { readdir as readdir8 } from "fs/promises";
5143
6675
  var WORKSPACE_REGEX = /^[a-z0-9_][a-z0-9-]*$/;
5144
6676
  function getWorkspaceParam(value) {
5145
6677
  if (Array.isArray(value)) {
@@ -5173,7 +6705,7 @@ function createTodosRouter(todosDir2, broadcast) {
5173
6705
  router.get("/", async (_req, res) => {
5174
6706
  try {
5175
6707
  await ensureDir(todosDir2);
5176
- const files = await readdir6(todosDir2).catch(() => []);
6708
+ const files = await readdir8(todosDir2).catch(() => []);
5177
6709
  const workspaces = [];
5178
6710
  for (const file of files) {
5179
6711
  if (typeof file !== "string") continue;
@@ -5278,8 +6810,8 @@ function createTodosRouter(todosDir2, broadcast) {
5278
6810
  router.post("/:workspace/archive", async (req, res) => {
5279
6811
  try {
5280
6812
  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");
6813
+ const { resolve: resolve18 } = await import("path");
6814
+ const { readFile: readFile14 } = await import("fs/promises");
5283
6815
  const { writeFileForce: writeFileForce2 } = await Promise.resolve().then(() => (init_fs(), fs_exports));
5284
6816
  const workspace = getWorkspaceParam(req.params.workspace);
5285
6817
  const checklist = await readChecklist(todosDir2, workspace);
@@ -5295,10 +6827,10 @@ function createTodosRouter(todosDir2, broadcast) {
5295
6827
  (e) => e.itemIds.every((id) => completedIds.has(id))
5296
6828
  );
5297
6829
  const archFile = archivePath2(todosDir2, workspace, checklist.archiveInterval);
5298
- await ensureDir(resolve16(todosDir2, "archive"));
6830
+ await ensureDir(resolve18(todosDir2, "archive"));
5299
6831
  let archContent = "";
5300
6832
  if (await fileExists(archFile)) {
5301
- archContent = await readFile12(archFile, "utf-8");
6833
+ archContent = await readFile14(archFile, "utf-8");
5302
6834
  archContent = archContent.trimEnd() + "\n\n";
5303
6835
  } else {
5304
6836
  archContent = `---
@@ -5558,8 +7090,8 @@ init_fs();
5558
7090
  init_config2();
5559
7091
  import { execFile as execFile2 } from "child_process";
5560
7092
  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";
7093
+ import { cp, mkdtemp, rm as rm2, readFile as readFile13, writeFile as writeFile4, unlink as unlink3, stat, open, rename as rename3 } from "fs/promises";
7094
+ import { resolve as resolve16, join as join2 } from "path";
5563
7095
  import { tmpdir } from "os";
5564
7096
  var exec2 = promisify2(execFile2);
5565
7097
  var VALID_CATEGORIES = ["projects", "playbooks", "todos", "servers", "config"];
@@ -5599,7 +7131,7 @@ async function resolveCategoryPath(category) {
5599
7131
  case "servers":
5600
7132
  return { sourcePath: serversDir(), repoPath: "servers", isFile: false };
5601
7133
  case "config":
5602
- return { sourcePath: resolve14(syntaurRoot(), "config.md"), repoPath: "config.md", isFile: true };
7134
+ return { sourcePath: resolve16(syntaurRoot(), "config.md"), repoPath: "config.md", isFile: true };
5603
7135
  }
5604
7136
  }
5605
7137
  async function checkGitInstalled() {
@@ -5610,7 +7142,7 @@ async function checkGitInstalled() {
5610
7142
  }
5611
7143
  }
5612
7144
  async function acquireLock() {
5613
- const lockPath = resolve14(syntaurRoot(), LOCK_FILE_NAME);
7145
+ const lockPath = resolve16(syntaurRoot(), LOCK_FILE_NAME);
5614
7146
  await ensureDir(syntaurRoot());
5615
7147
  try {
5616
7148
  const handle = await open(lockPath, "wx");
@@ -5619,7 +7151,7 @@ async function acquireLock() {
5619
7151
  return lockPath;
5620
7152
  } catch (err) {
5621
7153
  if (err.code === "EEXIST") {
5622
- const pid = await readFile11(lockPath, "utf-8").catch(() => "");
7154
+ const pid = await readFile13(lockPath, "utf-8").catch(() => "");
5623
7155
  throw new Error(
5624
7156
  `Backup operation already in progress (lock file at ${lockPath}, pid ${pid.trim() || "unknown"}). If stale, delete the file and retry.`
5625
7157
  );
@@ -5657,7 +7189,7 @@ async function copyRecursive(src, dest) {
5657
7189
  await ensureDir(dest);
5658
7190
  await cp(src, dest, { recursive: true, force: true });
5659
7191
  } else {
5660
- await ensureDir(resolve14(dest, ".."));
7192
+ await ensureDir(resolve16(dest, ".."));
5661
7193
  await cp(src, dest, { force: true });
5662
7194
  }
5663
7195
  }
@@ -5666,7 +7198,7 @@ function resolveCategoriesStrict(csv) {
5666
7198
  return parseCategoriesStrict(parts);
5667
7199
  }
5668
7200
  async function readSanitizedConfig(configPath) {
5669
- const content = await readFile11(configPath, "utf-8");
7201
+ const content = await readFile13(configPath, "utf-8");
5670
7202
  return content.replace(/^(\s*lastBackup:\s*).*$/m, "$1null").replace(/^(\s*lastRestore:\s*).*$/m, "$1null");
5671
7203
  }
5672
7204
  async function backupToGithub(overrides) {
@@ -5705,8 +7237,8 @@ async function backupToGithub(overrides) {
5705
7237
  }
5706
7238
  if (category === "config") {
5707
7239
  const sanitized = await readSanitizedConfig(sourcePath);
5708
- await ensureDir(resolve14(destPath, ".."));
5709
- await writeFile3(destPath, sanitized, "utf-8");
7240
+ await ensureDir(resolve16(destPath, ".."));
7241
+ await writeFile4(destPath, sanitized, "utf-8");
5710
7242
  } else {
5711
7243
  await copyRecursive(sourcePath, destPath);
5712
7244
  }
@@ -5759,7 +7291,7 @@ async function backupToGithub(overrides) {
5759
7291
  }
5760
7292
  async function safeRestoreCategory(localPath, repoSrcPath, isFile) {
5761
7293
  if (isFile) {
5762
- await ensureDir(resolve14(localPath, ".."));
7294
+ await ensureDir(resolve16(localPath, ".."));
5763
7295
  await cp(repoSrcPath, localPath, { force: true });
5764
7296
  return;
5765
7297
  }
@@ -5770,7 +7302,7 @@ async function safeRestoreCategory(localPath, repoSrcPath, isFile) {
5770
7302
  const localExistsBefore = await fileExists(localPath);
5771
7303
  if (backupExistsBefore) {
5772
7304
  if (!localExistsBefore) {
5773
- await rename2(backupPath, localPath);
7305
+ await rename3(backupPath, localPath);
5774
7306
  } else {
5775
7307
  throw new Error(
5776
7308
  `Cannot restore "${localPath}": a stale crash-recovery backup exists at ${backupPath} while the current path also exists. Inspect both and remove the one you don't need, then retry.`
@@ -5782,15 +7314,15 @@ async function safeRestoreCategory(localPath, repoSrcPath, isFile) {
5782
7314
  await cp(repoSrcPath, stagingPath, { recursive: true, force: true });
5783
7315
  const localExists = await fileExists(localPath);
5784
7316
  if (localExists) {
5785
- await rename2(localPath, backupPath);
7317
+ await rename3(localPath, backupPath);
5786
7318
  localMovedAside = true;
5787
7319
  }
5788
- await rename2(stagingPath, localPath);
7320
+ await rename3(stagingPath, localPath);
5789
7321
  await rm2(backupPath, { recursive: true, force: true }).catch(() => {
5790
7322
  });
5791
7323
  } catch (err) {
5792
7324
  if (localMovedAside && await fileExists(backupPath)) {
5793
- await rename2(backupPath, localPath).catch(() => {
7325
+ await rename3(backupPath, localPath).catch(() => {
5794
7326
  });
5795
7327
  }
5796
7328
  await rm2(stagingPath, { recursive: true, force: true }).catch(() => {
@@ -5860,7 +7392,7 @@ async function restoreFromGithub(overrides) {
5860
7392
  }
5861
7393
  async function getBackupStatus() {
5862
7394
  const config = await readConfig();
5863
- const lockPath = resolve14(syntaurRoot(), LOCK_FILE_NAME);
7395
+ const lockPath = resolve16(syntaurRoot(), LOCK_FILE_NAME);
5864
7396
  const locked = await fileExists(lockPath);
5865
7397
  return {
5866
7398
  repo: config.backup?.repo ?? null,
@@ -6015,7 +7547,7 @@ async function stopAutodiscovery() {
6015
7547
  function runReconcile() {
6016
7548
  if (activeReconcile || !savedOptions) return;
6017
7549
  const opts = savedOptions;
6018
- activeReconcile = reconcile(opts.serversDir, opts.projectsDir, opts.excludePids).catch((err) => {
7550
+ activeReconcile = reconcile(opts.serversDir, opts.projectsDir, opts.excludePids, opts.assignmentsDir).catch((err) => {
6019
7551
  console.error("[autodiscovery] reconcile failed:", err);
6020
7552
  }).finally(() => {
6021
7553
  activeReconcile = null;
@@ -6026,10 +7558,10 @@ async function listAllTmuxSessions() {
6026
7558
  if (!output) return [];
6027
7559
  return output.split("\n").filter((line) => line.length > 0);
6028
7560
  }
6029
- async function discoverTmuxSessions(serversDir2, projectsDir, existingNames) {
7561
+ async function discoverTmuxSessions(serversDir2, projectsDir, existingNames, assignmentsDir) {
6030
7562
  const tmuxAvailable = await checkTmuxAvailable();
6031
7563
  if (!tmuxAvailable) return false;
6032
- const workspaceRecords = await loadWorkspaceRecords(projectsDir);
7564
+ const workspaceRecords = await loadWorkspaceRecords(projectsDir, assignmentsDir);
6033
7565
  if (workspaceRecords.length === 0) return false;
6034
7566
  const sessions = await listAllTmuxSessions();
6035
7567
  let changed = false;
@@ -6070,8 +7602,8 @@ async function getProcessCwd(pid) {
6070
7602
  }
6071
7603
  return null;
6072
7604
  }
6073
- async function discoverProcesses(serversDir2, projectsDir, existingFiles, excludePids) {
6074
- const workspaceRecords = await loadWorkspaceRecords(projectsDir);
7605
+ async function discoverProcesses(serversDir2, projectsDir, existingFiles, excludePids, assignmentsDir) {
7606
+ const workspaceRecords = await loadWorkspaceRecords(projectsDir, assignmentsDir);
6075
7607
  if (workspaceRecords.length === 0) return false;
6076
7608
  const lsofOutput = await getLsofOutput();
6077
7609
  if (!lsofOutput) return false;
@@ -6136,7 +7668,7 @@ async function isProcessAlive(pid) {
6136
7668
  return false;
6137
7669
  }
6138
7670
  }
6139
- async function reconcile(serversDir2, projectsDir, excludePids) {
7671
+ async function reconcile(serversDir2, projectsDir, excludePids, assignmentsDir) {
6140
7672
  const names = await listSessionFiles(serversDir2);
6141
7673
  const existingFiles = /* @__PURE__ */ new Map();
6142
7674
  for (const name of names) {
@@ -6148,8 +7680,8 @@ async function reconcile(serversDir2, projectsDir, excludePids) {
6148
7680
  existingFiles.delete(name);
6149
7681
  }
6150
7682
  const existingNames = new Set(existingFiles.keys());
6151
- const tmuxChanged = await discoverTmuxSessions(serversDir2, projectsDir, existingNames);
6152
- const processChanged = await discoverProcesses(serversDir2, projectsDir, existingFiles, excludePids);
7683
+ const tmuxChanged = await discoverTmuxSessions(serversDir2, projectsDir, existingNames, assignmentsDir);
7684
+ const processChanged = await discoverProcesses(serversDir2, projectsDir, existingFiles, excludePids, assignmentsDir);
6153
7685
  if (tmuxChanged || processChanged || cleanupChanged) {
6154
7686
  clearScanCache();
6155
7687
  }
@@ -6157,7 +7689,7 @@ async function reconcile(serversDir2, projectsDir, excludePids) {
6157
7689
 
6158
7690
  // src/dashboard/server.ts
6159
7691
  function createDashboardServer(options) {
6160
- const { port, projectsDir, serversDir: serversDir2, playbooksDir: playbooksDir2, todosDir: todosDir2, serveStaticUi, dashboardDistPath } = options;
7692
+ const { port, projectsDir, assignmentsDir, serversDir: serversDir2, playbooksDir: playbooksDir2, todosDir: todosDir2, serveStaticUi, dashboardDistPath } = options;
6161
7693
  const app = express();
6162
7694
  const server = createServer(app);
6163
7695
  const wss = new WebSocketServer({ noServer: true });
@@ -6194,10 +7726,22 @@ function createDashboardServer(options) {
6194
7726
  migrateFromMarkdown(projectsDir).catch((err) => {
6195
7727
  console.error("Session migration from markdown failed:", err);
6196
7728
  });
7729
+ (async () => {
7730
+ try {
7731
+ const configResult = await migrateLegacyConfig(
7732
+ resolve17(syntaurRoot(), "config.md")
7733
+ );
7734
+ const projectResult = await migrateLegacyProjectFiles(projectsDir);
7735
+ const summary = summarizeMigration(projectResult, configResult);
7736
+ if (summary) console.log(summary);
7737
+ } catch (err) {
7738
+ console.error("Legacy filesystem migration failed:", err);
7739
+ }
7740
+ })();
6197
7741
  app.use(express.json());
6198
7742
  app.get("/api/overview", async (_req, res) => {
6199
7743
  try {
6200
- const overview = await getOverview(projectsDir, serversDir2);
7744
+ const overview = await getOverview(projectsDir, serversDir2, assignmentsDir);
6201
7745
  res.json(overview);
6202
7746
  } catch (error) {
6203
7747
  console.error("Error getting overview:", error);
@@ -6206,7 +7750,7 @@ function createDashboardServer(options) {
6206
7750
  });
6207
7751
  app.get("/api/attention", async (_req, res) => {
6208
7752
  try {
6209
- const attention = await getAttention(projectsDir, serversDir2);
7753
+ const attention = await getAttention(projectsDir, serversDir2, assignmentsDir);
6210
7754
  res.json(attention);
6211
7755
  } catch (error) {
6212
7756
  console.error("Error getting attention queue:", error);
@@ -6326,7 +7870,7 @@ function createDashboardServer(options) {
6326
7870
  });
6327
7871
  app.get("/api/assignments", async (req, res) => {
6328
7872
  try {
6329
- const result = await listAssignmentsBoard(projectsDir);
7873
+ const result = await listAssignmentsBoard(projectsDir, assignmentsDir);
6330
7874
  const workspaceParam = req.query.workspace;
6331
7875
  if (workspaceParam) {
6332
7876
  if (workspaceParam === "_ungrouped") {
@@ -6354,6 +7898,37 @@ function createDashboardServer(options) {
6354
7898
  res.status(500).json({ error: "Failed to get project detail" });
6355
7899
  }
6356
7900
  });
7901
+ app.get("/api/assignments/:id", async (req, res) => {
7902
+ try {
7903
+ const detail = await getAssignmentDetailById(projectsDir, assignmentsDir, req.params.id);
7904
+ if (!detail) {
7905
+ res.status(404).json({ error: `Assignment "${req.params.id}" not found` });
7906
+ return;
7907
+ }
7908
+ res.json(detail);
7909
+ } catch (error) {
7910
+ console.error("Error getting assignment by id:", error);
7911
+ res.status(500).json({ error: "Failed to get assignment" });
7912
+ }
7913
+ });
7914
+ app.get("/api/assignments/:id/sessions", async (req, res) => {
7915
+ try {
7916
+ const resolved = await resolveAssignmentById(projectsDir, assignmentsDir, req.params.id);
7917
+ if (!resolved) {
7918
+ res.status(404).json({ error: `Assignment "${req.params.id}" not found` });
7919
+ return;
7920
+ }
7921
+ await reconcileActiveSessions(projectsDir, assignmentsDir);
7922
+ const sessions = await listSessionsByAssignment(
7923
+ resolved.standalone ? null : resolved.projectSlug,
7924
+ resolved.standalone ? resolved.id : resolved.assignmentSlug
7925
+ );
7926
+ res.json({ sessions, generatedAt: (/* @__PURE__ */ new Date()).toISOString() });
7927
+ } catch (error) {
7928
+ console.error("Error listing sessions by id:", error);
7929
+ res.status(500).json({ error: "Failed to list sessions" });
7930
+ }
7931
+ });
6357
7932
  app.get("/api/projects/:slug/assignments/:aslug", async (req, res) => {
6358
7933
  try {
6359
7934
  const detail = await getAssignmentDetail(
@@ -6373,16 +7948,16 @@ function createDashboardServer(options) {
6373
7948
  res.status(500).json({ error: "Failed to get assignment detail" });
6374
7949
  }
6375
7950
  });
6376
- app.use(createWriteRouter(projectsDir));
6377
- app.use("/api/servers", createServersRouter(serversDir2, projectsDir));
6378
- app.use("/api/agent-sessions", createAgentSessionsRouter(projectsDir, broadcast));
7951
+ app.use(createWriteRouter(projectsDir, assignmentsDir));
7952
+ app.use("/api/servers", createServersRouter(serversDir2, projectsDir, assignmentsDir));
7953
+ app.use("/api/agent-sessions", createAgentSessionsRouter(projectsDir, broadcast, assignmentsDir));
6379
7954
  app.use("/api/playbooks", createPlaybooksRouter(playbooksDir2));
6380
7955
  app.use("/api/todos", createTodosRouter(todosDir2, broadcast));
6381
7956
  app.use("/api/backup", createBackupRouter());
6382
7957
  if (serveStaticUi && dashboardDistPath) {
6383
7958
  app.use(express.static(dashboardDistPath));
6384
7959
  app.get("{*path}", async (_req, res) => {
6385
- const indexPath = resolve15(dashboardDistPath, "index.html");
7960
+ const indexPath = resolve17(dashboardDistPath, "index.html");
6386
7961
  if (await fileExists(indexPath)) {
6387
7962
  res.sendFile(indexPath);
6388
7963
  } else {
@@ -6397,12 +7972,13 @@ function createDashboardServer(options) {
6397
7972
  async start() {
6398
7973
  watcherHandle = createWatcher({
6399
7974
  projectsDir,
7975
+ assignmentsDir,
6400
7976
  serversDir: serversDir2,
6401
7977
  playbooksDir: playbooksDir2,
6402
7978
  todosDir: todosDir2,
6403
7979
  onMessage: broadcast
6404
7980
  });
6405
- startAutodiscovery({ serversDir: serversDir2, projectsDir, excludePids: /* @__PURE__ */ new Set([process.pid]) });
7981
+ startAutodiscovery({ serversDir: serversDir2, projectsDir, assignmentsDir, excludePids: /* @__PURE__ */ new Set([process.pid]) });
6406
7982
  return new Promise((resolvePromise, reject) => {
6407
7983
  server.on("error", (err) => {
6408
7984
  if (err.code === "EADDRINUSE") {
@@ -6414,8 +7990,8 @@ function createDashboardServer(options) {
6414
7990
  }
6415
7991
  });
6416
7992
  server.listen(port, () => {
6417
- const portFile = resolve15(syntaurRoot(), "dashboard-port");
6418
- writeFile4(portFile, String(port), "utf-8").catch(() => {
7993
+ const portFile = resolve17(syntaurRoot(), "dashboard-port");
7994
+ writeFile5(portFile, String(port), "utf-8").catch(() => {
6419
7995
  });
6420
7996
  resolvePromise();
6421
7997
  });
@@ -6431,7 +8007,7 @@ function createDashboardServer(options) {
6431
8007
  client.terminate();
6432
8008
  }
6433
8009
  clients.clear();
6434
- const portFile = resolve15(syntaurRoot(), "dashboard-port");
8010
+ const portFile = resolve17(syntaurRoot(), "dashboard-port");
6435
8011
  await unlink4(portFile).catch(() => {
6436
8012
  });
6437
8013
  server.closeAllConnections?.();