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.
- package/dashboard/dist/assets/{_basePickBy-CHKX1r7P.js → _basePickBy-BhaCV7eH.js} +1 -1
- package/dashboard/dist/assets/{_baseUniq-CTxTc4MS.js → _baseUniq-CDPcqrs2.js} +1 -1
- package/dashboard/dist/assets/{arc-BUo5zftd.js → arc-BP0RxLwl.js} +1 -1
- package/dashboard/dist/assets/{architectureDiagram-2XIMDMQ5-CrJLm-P0.js → architectureDiagram-2XIMDMQ5-BDzvaeJp.js} +1 -1
- package/dashboard/dist/assets/{blockDiagram-WCTKOSBZ-BK60lBBJ.js → blockDiagram-WCTKOSBZ-ZeL9mROo.js} +1 -1
- package/dashboard/dist/assets/{c4Diagram-IC4MRINW-C7oJEvA0.js → c4Diagram-IC4MRINW-7S5bvFLp.js} +1 -1
- package/dashboard/dist/assets/channel-CcB_wcgb.js +1 -0
- package/dashboard/dist/assets/{chunk-4BX2VUAB-CjUPlzHz.js → chunk-4BX2VUAB-Ca7R4nv5.js} +1 -1
- package/dashboard/dist/assets/{chunk-55IACEB6-6HmWguiO.js → chunk-55IACEB6-flEv13FB.js} +1 -1
- package/dashboard/dist/assets/{chunk-FMBD7UC4-CLuJnd1b.js → chunk-FMBD7UC4-CfcYWBM6.js} +1 -1
- package/dashboard/dist/assets/{chunk-JSJVCQXG-B4d62qWV.js → chunk-JSJVCQXG-Dw4yL0VS.js} +1 -1
- package/dashboard/dist/assets/{chunk-KX2RTZJC-AsEKRPq2.js → chunk-KX2RTZJC-B2cDe40G.js} +1 -1
- package/dashboard/dist/assets/{chunk-NQ4KR5QH-DQhHHvwY.js → chunk-NQ4KR5QH-LZVm0IWg.js} +1 -1
- package/dashboard/dist/assets/{chunk-QZHKN3VN-Ds1TtI3E.js → chunk-QZHKN3VN-Dg0EeHNI.js} +1 -1
- package/dashboard/dist/assets/{chunk-WL4C6EOR-C7jE3-cR.js → chunk-WL4C6EOR-v3rXNwXc.js} +1 -1
- package/dashboard/dist/assets/classDiagram-VBA2DB6C-BJr38z2g.js +1 -0
- package/dashboard/dist/assets/classDiagram-v2-RAHNMMFH-BJr38z2g.js +1 -0
- package/dashboard/dist/assets/clone-Cfs2GUGt.js +1 -0
- package/dashboard/dist/assets/{cose-bilkent-S5V4N54A-C9ka5v1m.js → cose-bilkent-S5V4N54A-D-3JzLoS.js} +1 -1
- package/dashboard/dist/assets/{dagre-KLK3FWXG-BbgPQBKy.js → dagre-KLK3FWXG-d_mbczhU.js} +1 -1
- package/dashboard/dist/assets/{diagram-E7M64L7V-DpdeZFD4.js → diagram-E7M64L7V-BUyAp8pW.js} +1 -1
- package/dashboard/dist/assets/{diagram-IFDJBPK2-FlHLQzOV.js → diagram-IFDJBPK2-C8doXcyQ.js} +1 -1
- package/dashboard/dist/assets/{diagram-P4PSJMXO-B22NkEF_.js → diagram-P4PSJMXO-BUSmHa55.js} +1 -1
- package/dashboard/dist/assets/{erDiagram-INFDFZHY-zSqmtDid.js → erDiagram-INFDFZHY-Bn5_0LPU.js} +1 -1
- package/dashboard/dist/assets/{flowDiagram-PKNHOUZH-BP_0XmVV.js → flowDiagram-PKNHOUZH-CnEjerQM.js} +1 -1
- package/dashboard/dist/assets/{ganttDiagram-A5KZAMGK-8uRyYgZV.js → ganttDiagram-A5KZAMGK-CL94fbyy.js} +1 -1
- package/dashboard/dist/assets/{gitGraphDiagram-K3NZZRJ6-JFqg8sv4.js → gitGraphDiagram-K3NZZRJ6-4i_PeG8V.js} +1 -1
- package/dashboard/dist/assets/{graph-a-PAH599.js → graph-BtoFhoAd.js} +1 -1
- package/dashboard/dist/assets/index-DZUGYrvE.css +1 -0
- package/dashboard/dist/assets/index-Dv_-SxuL.js +481 -0
- package/dashboard/dist/assets/{infoDiagram-LFFYTUFH-C3kq7Nbv.js → infoDiagram-LFFYTUFH-CdUsuNgZ.js} +1 -1
- package/dashboard/dist/assets/{ishikawaDiagram-PHBUUO56-Kqi4EZ-n.js → ishikawaDiagram-PHBUUO56-BjggRlUx.js} +1 -1
- package/dashboard/dist/assets/{journeyDiagram-4ABVD52K-CTfv0Wcr.js → journeyDiagram-4ABVD52K-V4AgexlR.js} +1 -1
- package/dashboard/dist/assets/{kanban-definition-K7BYSVSG-Dmx0lgvR.js → kanban-definition-K7BYSVSG-ChlylQRf.js} +1 -1
- package/dashboard/dist/assets/{layout-KKRbT2Od.js → layout-DLcz9AmA.js} +1 -1
- package/dashboard/dist/assets/{linear-5egaBiw7.js → linear-l2xnSHze.js} +1 -1
- package/dashboard/dist/assets/{mermaid.core-C9pF_oFQ.js → mermaid.core-DKO1ytRW.js} +4 -4
- package/dashboard/dist/assets/{mindmap-definition-YRQLILUH-C7HXYEXt.js → mindmap-definition-YRQLILUH-DTmTPHrT.js} +1 -1
- package/dashboard/dist/assets/{pieDiagram-SKSYHLDU-DkdZm-YP.js → pieDiagram-SKSYHLDU-CwK80y8Y.js} +1 -1
- package/dashboard/dist/assets/{quadrantDiagram-337W2JSQ-DkcRJs5F.js → quadrantDiagram-337W2JSQ-Be1xqW_w.js} +1 -1
- package/dashboard/dist/assets/{requirementDiagram-Z7DCOOCP-BaTDVYTl.js → requirementDiagram-Z7DCOOCP-JcspXCs0.js} +1 -1
- package/dashboard/dist/assets/{sankeyDiagram-WA2Y5GQK-DvPLbGV5.js → sankeyDiagram-WA2Y5GQK-nJb1BInq.js} +1 -1
- package/dashboard/dist/assets/{sequenceDiagram-2WXFIKYE-DQoZ2xMK.js → sequenceDiagram-2WXFIKYE-DUrclEgA.js} +1 -1
- package/dashboard/dist/assets/{stateDiagram-RAJIS63D-CS4l0OjM.js → stateDiagram-RAJIS63D-CjinnNtF.js} +1 -1
- package/dashboard/dist/assets/stateDiagram-v2-FVOUBMTO-yfclw-nM.js +1 -0
- package/dashboard/dist/assets/{timeline-definition-YZTLITO2-aC0iCFCW.js → timeline-definition-YZTLITO2-kM-oVLNz.js} +1 -1
- package/dashboard/dist/assets/{treemap-KZPCXAKY-Ie-PFjgx.js → treemap-KZPCXAKY-CYziFlrQ.js} +1 -1
- package/dashboard/dist/assets/{vennDiagram-LZ73GAT5-CJN3ExTQ.js → vennDiagram-LZ73GAT5-DX0DbxBN.js} +1 -1
- package/dashboard/dist/assets/{xychartDiagram-JWTSCODW-DSiDu1CN.js → xychartDiagram-JWTSCODW-BGqM42ZM.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/dashboard/server.d.ts +5 -0
- package/dist/dashboard/server.js +2185 -609
- package/dist/dashboard/server.js.map +1 -1
- package/dist/index.js +2596 -959
- package/dist/index.js.map +1 -1
- package/examples/playbooks/keep-records-updated.md +14 -8
- package/examples/playbooks/read-before-plan.md +8 -5
- package/examples/sample-project/_status.md +1 -1
- package/examples/sample-project/assignments/design-auth-schema/assignment.md +4 -17
- package/examples/sample-project/assignments/design-auth-schema/comments.md +26 -0
- package/examples/sample-project/assignments/design-auth-schema/progress.md +20 -0
- package/examples/sample-project/assignments/implement-jwt-middleware/assignment.md +4 -17
- package/examples/sample-project/assignments/implement-jwt-middleware/comments.md +17 -0
- package/examples/sample-project/assignments/implement-jwt-middleware/progress.md +20 -0
- package/examples/sample-project/assignments/write-auth-tests/assignment.md +4 -8
- package/examples/sample-project/assignments/write-auth-tests/comments.md +10 -0
- package/examples/sample-project/assignments/write-auth-tests/progress.md +10 -0
- package/package.json +1 -1
- package/platforms/claude-code/.claude-plugin/plugin.json +5 -1
- package/platforms/claude-code/agents/syntaur-expert.md +46 -15
- package/platforms/claude-code/commands/track-session/track-session.md +43 -18
- package/platforms/claude-code/hooks/hooks.json +11 -0
- package/platforms/claude-code/hooks/session-cleanup.sh +13 -23
- package/platforms/claude-code/hooks/session-start.sh +80 -0
- package/platforms/claude-code/hooks/statusline.sh +110 -0
- package/platforms/claude-code/references/file-ownership.md +15 -3
- package/platforms/claude-code/references/protocol-summary.md +19 -5
- package/platforms/claude-code/skills/complete-assignment/SKILL.md +14 -0
- package/platforms/claude-code/skills/create-assignment/SKILL.md +12 -10
- package/platforms/claude-code/skills/grab-assignment/SKILL.md +30 -15
- package/platforms/claude-code/skills/plan-assignment/SKILL.md +16 -8
- package/platforms/claude-code/skills/syntaur-protocol/SKILL.md +21 -11
- package/platforms/codex/.codex-plugin/plugin.json +1 -1
- package/platforms/codex/agents/syntaur-operator.md +39 -25
- package/platforms/codex/references/file-ownership.md +14 -3
- package/platforms/codex/references/protocol-summary.md +19 -5
- package/platforms/codex/scripts/resolve-session.sh +49 -0
- package/platforms/codex/skills/complete-assignment/SKILL.md +1 -0
- package/platforms/codex/skills/create-assignment/SKILL.md +13 -8
- package/platforms/codex/skills/grab-assignment/SKILL.md +7 -5
- package/platforms/codex/skills/plan-assignment/SKILL.md +8 -4
- package/platforms/codex/skills/syntaur-protocol/SKILL.md +26 -13
- package/dashboard/dist/assets/channel-DdltvFFH.js +0 -1
- package/dashboard/dist/assets/classDiagram-VBA2DB6C-BHqdFE-8.js +0 -1
- package/dashboard/dist/assets/classDiagram-v2-RAHNMMFH-BHqdFE-8.js +0 -1
- package/dashboard/dist/assets/clone-CBJOOeOm.js +0 -1
- package/dashboard/dist/assets/index-CoVCLSh2.css +0 -1
- package/dashboard/dist/assets/index-yyAIuzrP.js +0 -471
- package/dashboard/dist/assets/stateDiagram-v2-FVOUBMTO-DkBtE1WJ.js +0 -1
package/dist/dashboard/server.js
CHANGED
|
@@ -389,6 +389,52 @@ async function executeTransition(projectDir, assignmentSlug, command, options =
|
|
|
389
389
|
warnings: warnings.length > 0 ? warnings : void 0
|
|
390
390
|
};
|
|
391
391
|
}
|
|
392
|
+
async function executeTransitionByDir(assignmentDir, command, options = {}) {
|
|
393
|
+
const filePath = resolve2(assignmentDir, "assignment.md");
|
|
394
|
+
const { content, frontmatter } = await readAssignment(filePath);
|
|
395
|
+
const targetStatus = getTargetStatus(frontmatter.status, command, options.transitionTable);
|
|
396
|
+
if (!targetStatus) {
|
|
397
|
+
return {
|
|
398
|
+
success: false,
|
|
399
|
+
message: `Unknown command '${command}' for assignment "${frontmatter.slug || assignmentDir}".`,
|
|
400
|
+
fromStatus: frontmatter.status
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
const warnings = [];
|
|
404
|
+
if (command === "start" && !options.standalone && frontmatter.dependsOn.length > 0) {
|
|
405
|
+
const projectDir = resolve2(assignmentDir, "..", "..");
|
|
406
|
+
const depCheck = await checkDependencies(
|
|
407
|
+
projectDir,
|
|
408
|
+
frontmatter.dependsOn,
|
|
409
|
+
options.terminalStatuses
|
|
410
|
+
);
|
|
411
|
+
if (!depCheck.satisfied) {
|
|
412
|
+
warnings.push(`Starting with unmet dependencies: ${depCheck.unmet.join(", ")}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const updates = {
|
|
416
|
+
status: targetStatus,
|
|
417
|
+
updated: nowTimestamp()
|
|
418
|
+
};
|
|
419
|
+
if (command === "start" && options.agent && !frontmatter.assignee) {
|
|
420
|
+
updates.assignee = options.agent;
|
|
421
|
+
}
|
|
422
|
+
if (command === "block") {
|
|
423
|
+
updates.blockedReason = options.reason ?? null;
|
|
424
|
+
}
|
|
425
|
+
if (command === "unblock") {
|
|
426
|
+
updates.blockedReason = null;
|
|
427
|
+
}
|
|
428
|
+
const updatedContent = updateAssignmentFile(content, updates);
|
|
429
|
+
await writeFileForce(filePath, updatedContent);
|
|
430
|
+
return {
|
|
431
|
+
success: true,
|
|
432
|
+
message: `Assignment "${frontmatter.slug || assignmentDir}" transitioned: ${frontmatter.status} -> ${targetStatus}`,
|
|
433
|
+
fromStatus: frontmatter.status,
|
|
434
|
+
toStatus: targetStatus,
|
|
435
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
436
|
+
};
|
|
437
|
+
}
|
|
392
438
|
var init_transitions = __esm({
|
|
393
439
|
"src/lifecycle/transitions.ts"() {
|
|
394
440
|
"use strict";
|
|
@@ -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
|
|
443
|
-
import { resolve as
|
|
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
|
|
811
|
+
return resolve4(expanded);
|
|
631
812
|
}
|
|
632
813
|
async function writeStatusConfig(statuses) {
|
|
633
|
-
const configPath =
|
|
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
|
|
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 =
|
|
868
|
+
const configPath = resolve4(syntaurRoot(), "config.md");
|
|
688
869
|
if (!await fileExists(configPath)) return;
|
|
689
|
-
const existing = await
|
|
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 =
|
|
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
|
|
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 =
|
|
916
|
+
const configPath = resolve4(syntaurRoot(), "config.md");
|
|
736
917
|
if (!await fileExists(configPath)) {
|
|
737
918
|
return { ...DEFAULT_CONFIG };
|
|
738
919
|
}
|
|
739
|
-
|
|
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
|
|
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,
|
|
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
|
|
1464
|
-
import { resolve as
|
|
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(
|
|
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
|
|
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 =
|
|
1829
|
+
const filePath = resolve6(dir, `${sanitizeSessionName(name)}.md`);
|
|
1522
1830
|
if (!await fileExists(filePath)) return null;
|
|
1523
|
-
const raw = await
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
1634
|
-
import { realpath, readdir as
|
|
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(
|
|
1730
|
-
const resolvedGit = await realpath(
|
|
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
|
|
2051
|
+
const projectAssignmentsDir = resolve7(projectsDir, project.slug, "assignments");
|
|
1744
2052
|
let slugs;
|
|
1745
2053
|
try {
|
|
1746
|
-
slugs = await
|
|
2054
|
+
slugs = await readdir4(projectAssignmentsDir);
|
|
1747
2055
|
} catch {
|
|
1748
2056
|
continue;
|
|
1749
2057
|
}
|
|
1750
2058
|
for (const aslug of slugs) {
|
|
1751
|
-
const aFile =
|
|
2059
|
+
const aFile = resolve7(projectAssignmentsDir, aslug, "assignment.md");
|
|
1752
2060
|
try {
|
|
1753
|
-
const raw = await
|
|
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
|
|
2002
|
-
import { resolve as
|
|
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 =
|
|
2415
|
+
const registryPath = resolve8(dirname2(projectsDir), "workspaces.json");
|
|
2064
2416
|
try {
|
|
2065
|
-
const raw = await
|
|
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 =
|
|
2074
|
-
await
|
|
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
|
|
2109
|
-
const
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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 =
|
|
2247
|
-
const projectMdPath =
|
|
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
|
|
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 =
|
|
2282
|
-
const assignmentMdPath =
|
|
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
|
|
2705
|
+
const assignmentContent = await readFile7(assignmentMdPath, "utf-8");
|
|
2287
2706
|
const assignment = parseAssignmentFull(assignmentContent);
|
|
2288
2707
|
let plan = null;
|
|
2289
|
-
const planPath =
|
|
2708
|
+
const planPath = resolve8(assignmentDir, "plan.md");
|
|
2290
2709
|
if (await fileExists(planPath)) {
|
|
2291
|
-
const planContent = await
|
|
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 =
|
|
2719
|
+
const scratchpadPath = resolve8(assignmentDir, "scratchpad.md");
|
|
2301
2720
|
if (await fileExists(scratchpadPath)) {
|
|
2302
|
-
const scratchpadContent = await
|
|
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 =
|
|
2729
|
+
const handoffPath = resolve8(assignmentDir, "handoff.md");
|
|
2311
2730
|
if (await fileExists(handoffPath)) {
|
|
2312
|
-
const handoffContent = await
|
|
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 =
|
|
2740
|
+
const decisionRecordPath = resolve8(assignmentDir, "decision-record.md");
|
|
2322
2741
|
if (await fileExists(decisionRecordPath)) {
|
|
2323
|
-
const decisionRecordContent = await
|
|
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
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
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
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
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
|
-
|
|
2460
|
-
|
|
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
|
|
2463
|
-
const
|
|
2464
|
-
|
|
2465
|
-
|
|
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
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
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
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
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
|
-
|
|
2478
|
-
|
|
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 =
|
|
3119
|
+
const resourcesDir = resolve8(projectPath, "resources");
|
|
2485
3120
|
if (!await fileExists(resourcesDir)) {
|
|
2486
3121
|
return [];
|
|
2487
3122
|
}
|
|
2488
|
-
const entries = await
|
|
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 =
|
|
2495
|
-
const content = await
|
|
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 =
|
|
3145
|
+
const memoriesDir = resolve8(projectPath, "memories");
|
|
2511
3146
|
if (!await fileExists(memoriesDir)) {
|
|
2512
3147
|
return [];
|
|
2513
3148
|
}
|
|
2514
|
-
const entries = await
|
|
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 =
|
|
2521
|
-
const content = await
|
|
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 =
|
|
3171
|
+
const statusPath = resolve8(projectPath, "_status.md");
|
|
2537
3172
|
if (await fileExists(statusPath)) {
|
|
2538
|
-
const statusContent = await
|
|
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 +=
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
2787
|
-
const
|
|
2788
|
-
|
|
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
|
|
3494
|
+
return resolve8(projectsDir, projectSlug, "project.md");
|
|
2803
3495
|
case "assignment":
|
|
2804
|
-
return assignmentSlug ?
|
|
3496
|
+
return assignmentSlug ? resolve8(projectsDir, projectSlug, "assignments", assignmentSlug, "assignment.md") : null;
|
|
2805
3497
|
case "plan":
|
|
2806
|
-
return assignmentSlug ?
|
|
3498
|
+
return assignmentSlug ? resolve8(projectsDir, projectSlug, "assignments", assignmentSlug, "plan.md") : null;
|
|
2807
3499
|
case "scratchpad":
|
|
2808
|
-
return assignmentSlug ?
|
|
3500
|
+
return assignmentSlug ? resolve8(projectsDir, projectSlug, "assignments", assignmentSlug, "scratchpad.md") : null;
|
|
2809
3501
|
case "handoff":
|
|
2810
|
-
return assignmentSlug ?
|
|
3502
|
+
return assignmentSlug ? resolve8(projectsDir, projectSlug, "assignments", assignmentSlug, "handoff.md") : null;
|
|
2811
3503
|
case "decision-record":
|
|
2812
|
-
return assignmentSlug ?
|
|
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
|
|
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 =
|
|
2844
|
-
const raw = await
|
|
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 =
|
|
3552
|
+
const filePath = resolve8(playbooksDir2, `${slug}.md`);
|
|
2861
3553
|
if (!await fileExists(filePath)) return null;
|
|
2862
|
-
const raw = await
|
|
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
|
|
2975
|
-
import { resolve as
|
|
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
|
|
3831
|
+
return resolve15(todosDir2, `${workspace}.md`);
|
|
3136
3832
|
}
|
|
3137
3833
|
function logPath(todosDir2, workspace) {
|
|
3138
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3229
|
-
import { writeFile as
|
|
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/
|
|
3233
|
-
|
|
3234
|
-
import {
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
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
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
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
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
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
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
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
|
|
3383
|
-
import { rm, readFile as
|
|
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/
|
|
3612
|
-
function
|
|
4675
|
+
// src/templates/progress.ts
|
|
4676
|
+
function renderProgress(params) {
|
|
3613
4677
|
return `---
|
|
3614
|
-
assignment: ${params.
|
|
4678
|
+
assignment: ${params.assignment}
|
|
4679
|
+
entryCount: 0
|
|
4680
|
+
generated: "${params.timestamp}"
|
|
3615
4681
|
updated: "${params.timestamp}"
|
|
3616
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
3993
|
-
await ensureDir(
|
|
3994
|
-
await ensureDir(
|
|
3995
|
-
await writeFileForce(
|
|
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
|
-
[
|
|
3999
|
-
[
|
|
4000
|
-
[
|
|
4001
|
-
[
|
|
4002
|
-
[
|
|
4003
|
-
[
|
|
4004
|
-
[
|
|
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 =
|
|
4026
|
-
const projectMdPath =
|
|
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 =
|
|
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(
|
|
5179
|
+
await writeFileForce(resolve11(assignmentDir, "assignment.md"), content);
|
|
4066
5180
|
try {
|
|
4067
5181
|
const companions = [
|
|
4068
|
-
[
|
|
4069
|
-
[
|
|
4070
|
-
[
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
4449
|
-
const assignmentPath =
|
|
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 =
|
|
4476
|
-
const assignmentPath =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
4969
|
-
import { readFile as
|
|
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
|
|
4978
|
-
import { readdir as
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
|
5282
|
-
const { readFile:
|
|
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(
|
|
6830
|
+
await ensureDir(resolve18(todosDir2, "archive"));
|
|
5299
6831
|
let archContent = "";
|
|
5300
6832
|
if (await fileExists(archFile)) {
|
|
5301
|
-
archContent = await
|
|
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
|
|
5562
|
-
import { resolve as
|
|
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:
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
5709
|
-
await
|
|
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(
|
|
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
|
|
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
|
|
7317
|
+
await rename3(localPath, backupPath);
|
|
5786
7318
|
localMovedAside = true;
|
|
5787
7319
|
}
|
|
5788
|
-
await
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
6418
|
-
|
|
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 =
|
|
8010
|
+
const portFile = resolve17(syntaurRoot(), "dashboard-port");
|
|
6435
8011
|
await unlink4(portFile).catch(() => {
|
|
6436
8012
|
});
|
|
6437
8013
|
server.closeAllConnections?.();
|