syntaur 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/dist/assets/{_basePickBy-CHKX1r7P.js → _basePickBy-BhaCV7eH.js} +1 -1
- package/dashboard/dist/assets/{_baseUniq-CTxTc4MS.js → _baseUniq-CDPcqrs2.js} +1 -1
- package/dashboard/dist/assets/{arc-BUo5zftd.js → arc-BP0RxLwl.js} +1 -1
- package/dashboard/dist/assets/{architectureDiagram-2XIMDMQ5-CrJLm-P0.js → architectureDiagram-2XIMDMQ5-BDzvaeJp.js} +1 -1
- package/dashboard/dist/assets/{blockDiagram-WCTKOSBZ-BK60lBBJ.js → blockDiagram-WCTKOSBZ-ZeL9mROo.js} +1 -1
- package/dashboard/dist/assets/{c4Diagram-IC4MRINW-C7oJEvA0.js → c4Diagram-IC4MRINW-7S5bvFLp.js} +1 -1
- package/dashboard/dist/assets/channel-CcB_wcgb.js +1 -0
- package/dashboard/dist/assets/{chunk-4BX2VUAB-CjUPlzHz.js → chunk-4BX2VUAB-Ca7R4nv5.js} +1 -1
- package/dashboard/dist/assets/{chunk-55IACEB6-6HmWguiO.js → chunk-55IACEB6-flEv13FB.js} +1 -1
- package/dashboard/dist/assets/{chunk-FMBD7UC4-CLuJnd1b.js → chunk-FMBD7UC4-CfcYWBM6.js} +1 -1
- package/dashboard/dist/assets/{chunk-JSJVCQXG-B4d62qWV.js → chunk-JSJVCQXG-Dw4yL0VS.js} +1 -1
- package/dashboard/dist/assets/{chunk-KX2RTZJC-AsEKRPq2.js → chunk-KX2RTZJC-B2cDe40G.js} +1 -1
- package/dashboard/dist/assets/{chunk-NQ4KR5QH-DQhHHvwY.js → chunk-NQ4KR5QH-LZVm0IWg.js} +1 -1
- package/dashboard/dist/assets/{chunk-QZHKN3VN-Ds1TtI3E.js → chunk-QZHKN3VN-Dg0EeHNI.js} +1 -1
- package/dashboard/dist/assets/{chunk-WL4C6EOR-C7jE3-cR.js → chunk-WL4C6EOR-v3rXNwXc.js} +1 -1
- package/dashboard/dist/assets/classDiagram-VBA2DB6C-BJr38z2g.js +1 -0
- package/dashboard/dist/assets/classDiagram-v2-RAHNMMFH-BJr38z2g.js +1 -0
- package/dashboard/dist/assets/clone-Cfs2GUGt.js +1 -0
- package/dashboard/dist/assets/{cose-bilkent-S5V4N54A-C9ka5v1m.js → cose-bilkent-S5V4N54A-D-3JzLoS.js} +1 -1
- package/dashboard/dist/assets/{dagre-KLK3FWXG-BbgPQBKy.js → dagre-KLK3FWXG-d_mbczhU.js} +1 -1
- package/dashboard/dist/assets/{diagram-E7M64L7V-DpdeZFD4.js → diagram-E7M64L7V-BUyAp8pW.js} +1 -1
- package/dashboard/dist/assets/{diagram-IFDJBPK2-FlHLQzOV.js → diagram-IFDJBPK2-C8doXcyQ.js} +1 -1
- package/dashboard/dist/assets/{diagram-P4PSJMXO-B22NkEF_.js → diagram-P4PSJMXO-BUSmHa55.js} +1 -1
- package/dashboard/dist/assets/{erDiagram-INFDFZHY-zSqmtDid.js → erDiagram-INFDFZHY-Bn5_0LPU.js} +1 -1
- package/dashboard/dist/assets/{flowDiagram-PKNHOUZH-BP_0XmVV.js → flowDiagram-PKNHOUZH-CnEjerQM.js} +1 -1
- package/dashboard/dist/assets/{ganttDiagram-A5KZAMGK-8uRyYgZV.js → ganttDiagram-A5KZAMGK-CL94fbyy.js} +1 -1
- package/dashboard/dist/assets/{gitGraphDiagram-K3NZZRJ6-JFqg8sv4.js → gitGraphDiagram-K3NZZRJ6-4i_PeG8V.js} +1 -1
- package/dashboard/dist/assets/{graph-a-PAH599.js → graph-BtoFhoAd.js} +1 -1
- package/dashboard/dist/assets/index-DZUGYrvE.css +1 -0
- package/dashboard/dist/assets/index-Dv_-SxuL.js +481 -0
- package/dashboard/dist/assets/{infoDiagram-LFFYTUFH-C3kq7Nbv.js → infoDiagram-LFFYTUFH-CdUsuNgZ.js} +1 -1
- package/dashboard/dist/assets/{ishikawaDiagram-PHBUUO56-Kqi4EZ-n.js → ishikawaDiagram-PHBUUO56-BjggRlUx.js} +1 -1
- package/dashboard/dist/assets/{journeyDiagram-4ABVD52K-CTfv0Wcr.js → journeyDiagram-4ABVD52K-V4AgexlR.js} +1 -1
- package/dashboard/dist/assets/{kanban-definition-K7BYSVSG-Dmx0lgvR.js → kanban-definition-K7BYSVSG-ChlylQRf.js} +1 -1
- package/dashboard/dist/assets/{layout-KKRbT2Od.js → layout-DLcz9AmA.js} +1 -1
- package/dashboard/dist/assets/{linear-5egaBiw7.js → linear-l2xnSHze.js} +1 -1
- package/dashboard/dist/assets/{mermaid.core-C9pF_oFQ.js → mermaid.core-DKO1ytRW.js} +4 -4
- package/dashboard/dist/assets/{mindmap-definition-YRQLILUH-C7HXYEXt.js → mindmap-definition-YRQLILUH-DTmTPHrT.js} +1 -1
- package/dashboard/dist/assets/{pieDiagram-SKSYHLDU-DkdZm-YP.js → pieDiagram-SKSYHLDU-CwK80y8Y.js} +1 -1
- package/dashboard/dist/assets/{quadrantDiagram-337W2JSQ-DkcRJs5F.js → quadrantDiagram-337W2JSQ-Be1xqW_w.js} +1 -1
- package/dashboard/dist/assets/{requirementDiagram-Z7DCOOCP-BaTDVYTl.js → requirementDiagram-Z7DCOOCP-JcspXCs0.js} +1 -1
- package/dashboard/dist/assets/{sankeyDiagram-WA2Y5GQK-DvPLbGV5.js → sankeyDiagram-WA2Y5GQK-nJb1BInq.js} +1 -1
- package/dashboard/dist/assets/{sequenceDiagram-2WXFIKYE-DQoZ2xMK.js → sequenceDiagram-2WXFIKYE-DUrclEgA.js} +1 -1
- package/dashboard/dist/assets/{stateDiagram-RAJIS63D-CS4l0OjM.js → stateDiagram-RAJIS63D-CjinnNtF.js} +1 -1
- package/dashboard/dist/assets/stateDiagram-v2-FVOUBMTO-yfclw-nM.js +1 -0
- package/dashboard/dist/assets/{timeline-definition-YZTLITO2-aC0iCFCW.js → timeline-definition-YZTLITO2-kM-oVLNz.js} +1 -1
- package/dashboard/dist/assets/{treemap-KZPCXAKY-Ie-PFjgx.js → treemap-KZPCXAKY-CYziFlrQ.js} +1 -1
- package/dashboard/dist/assets/{vennDiagram-LZ73GAT5-CJN3ExTQ.js → vennDiagram-LZ73GAT5-DX0DbxBN.js} +1 -1
- package/dashboard/dist/assets/{xychartDiagram-JWTSCODW-DSiDu1CN.js → xychartDiagram-JWTSCODW-BGqM42ZM.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/dashboard/server.d.ts +5 -0
- package/dist/dashboard/server.js +2579 -1185
- package/dist/dashboard/server.js.map +1 -1
- package/dist/index.js +2092 -650
- package/dist/index.js.map +1 -1
- package/examples/playbooks/keep-records-updated.md +14 -8
- package/examples/playbooks/read-before-plan.md +8 -5
- package/examples/sample-project/_status.md +1 -1
- package/examples/sample-project/assignments/design-auth-schema/assignment.md +4 -17
- package/examples/sample-project/assignments/design-auth-schema/comments.md +26 -0
- package/examples/sample-project/assignments/design-auth-schema/progress.md +20 -0
- package/examples/sample-project/assignments/implement-jwt-middleware/assignment.md +4 -17
- package/examples/sample-project/assignments/implement-jwt-middleware/comments.md +17 -0
- package/examples/sample-project/assignments/implement-jwt-middleware/progress.md +20 -0
- package/examples/sample-project/assignments/write-auth-tests/assignment.md +4 -8
- package/examples/sample-project/assignments/write-auth-tests/comments.md +10 -0
- package/examples/sample-project/assignments/write-auth-tests/progress.md +10 -0
- package/package.json +1 -1
- package/platforms/claude-code/agents/syntaur-expert.md +40 -12
- package/platforms/claude-code/references/file-ownership.md +15 -3
- package/platforms/claude-code/references/protocol-summary.md +19 -5
- package/platforms/claude-code/skills/complete-assignment/SKILL.md +14 -0
- package/platforms/claude-code/skills/create-assignment/SKILL.md +12 -10
- package/platforms/claude-code/skills/syntaur-protocol/SKILL.md +21 -11
- package/platforms/codex/agents/syntaur-operator.md +33 -21
- package/platforms/codex/references/file-ownership.md +14 -3
- package/platforms/codex/references/protocol-summary.md +19 -5
- package/platforms/codex/skills/complete-assignment/SKILL.md +1 -0
- package/platforms/codex/skills/create-assignment/SKILL.md +13 -8
- package/platforms/codex/skills/syntaur-protocol/SKILL.md +26 -13
- package/dashboard/dist/assets/channel-DdltvFFH.js +0 -1
- package/dashboard/dist/assets/classDiagram-VBA2DB6C-BHqdFE-8.js +0 -1
- package/dashboard/dist/assets/classDiagram-v2-RAHNMMFH-BHqdFE-8.js +0 -1
- package/dashboard/dist/assets/clone-CBJOOeOm.js +0 -1
- package/dashboard/dist/assets/index-CoVCLSh2.css +0 -1
- package/dashboard/dist/assets/index-yyAIuzrP.js +0 -471
- package/dashboard/dist/assets/stateDiagram-v2-FVOUBMTO-DkBtE1WJ.js +0 -1
package/dist/index.js
CHANGED
|
@@ -302,6 +302,58 @@ function parseDecisionRecord(fileContent) {
|
|
|
302
302
|
body
|
|
303
303
|
};
|
|
304
304
|
}
|
|
305
|
+
function parseComments(fileContent) {
|
|
306
|
+
const [fm, body] = extractFrontmatter(fileContent);
|
|
307
|
+
const entries = [];
|
|
308
|
+
const sections = body.split(/^## /m).slice(1);
|
|
309
|
+
for (const section of sections) {
|
|
310
|
+
const newlineIdx = section.indexOf("\n");
|
|
311
|
+
if (newlineIdx === -1) continue;
|
|
312
|
+
const id = section.slice(0, newlineIdx).trim();
|
|
313
|
+
const rest = section.slice(newlineIdx + 1);
|
|
314
|
+
const headerMatch = rest.match(
|
|
315
|
+
/^\s*\*\*Recorded:\*\*\s*(.*)\n\*\*Author:\*\*\s*(.*)\n\*\*Type:\*\*\s*(question|note|feedback)(?:\n\*\*Reply to:\*\*\s*(.*))?(?:\n\*\*Resolved:\*\*\s*(true|false))?\n+([\s\S]*)$/
|
|
316
|
+
);
|
|
317
|
+
if (!headerMatch) continue;
|
|
318
|
+
const [, timestamp, author, type, replyTo, resolvedStr, entryBody] = headerMatch;
|
|
319
|
+
const entry = {
|
|
320
|
+
id,
|
|
321
|
+
timestamp: timestamp.trim(),
|
|
322
|
+
author: author.trim(),
|
|
323
|
+
type,
|
|
324
|
+
body: entryBody.trim()
|
|
325
|
+
};
|
|
326
|
+
if (replyTo) entry.replyTo = replyTo.trim();
|
|
327
|
+
if (resolvedStr) entry.resolved = resolvedStr === "true";
|
|
328
|
+
entries.push(entry);
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
assignment: getField(fm, "assignment") ?? "",
|
|
332
|
+
entryCount: parseInt(getField(fm, "entryCount") ?? "0", 10),
|
|
333
|
+
updated: getField(fm, "updated") ?? "",
|
|
334
|
+
entries,
|
|
335
|
+
body
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function parseProgress(fileContent) {
|
|
339
|
+
const [fm, body] = extractFrontmatter(fileContent);
|
|
340
|
+
const entries = [];
|
|
341
|
+
const sections = body.split(/^## /m).slice(1);
|
|
342
|
+
for (const section of sections) {
|
|
343
|
+
const newlineIdx = section.indexOf("\n");
|
|
344
|
+
if (newlineIdx === -1) continue;
|
|
345
|
+
const timestamp = section.slice(0, newlineIdx).trim();
|
|
346
|
+
const entryBody = section.slice(newlineIdx + 1).trim();
|
|
347
|
+
entries.push({ timestamp, body: entryBody });
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
assignment: getField(fm, "assignment") ?? "",
|
|
351
|
+
entryCount: parseInt(getField(fm, "entryCount") ?? "0", 10),
|
|
352
|
+
updated: getField(fm, "updated") ?? "",
|
|
353
|
+
entries,
|
|
354
|
+
body
|
|
355
|
+
};
|
|
356
|
+
}
|
|
305
357
|
function parseResource(fileContent) {
|
|
306
358
|
const [fm, body] = extractFrontmatter(fileContent);
|
|
307
359
|
return {
|
|
@@ -1206,6 +1258,74 @@ var init_lifecycle = __esm({
|
|
|
1206
1258
|
}
|
|
1207
1259
|
});
|
|
1208
1260
|
|
|
1261
|
+
// src/utils/assignment-resolver.ts
|
|
1262
|
+
import { resolve as resolve8 } from "path";
|
|
1263
|
+
import { readdir as readdir3, readFile as readFile5 } from "fs/promises";
|
|
1264
|
+
async function resolveAssignmentById(projectsDir2, assignmentsDir2, id) {
|
|
1265
|
+
let standaloneMatch = null;
|
|
1266
|
+
let projectMatch = null;
|
|
1267
|
+
const standaloneDir = resolve8(assignmentsDir2, id);
|
|
1268
|
+
const standalonePath = resolve8(standaloneDir, "assignment.md");
|
|
1269
|
+
if (await fileExists(standalonePath)) {
|
|
1270
|
+
standaloneMatch = {
|
|
1271
|
+
assignmentDir: standaloneDir,
|
|
1272
|
+
projectSlug: null,
|
|
1273
|
+
assignmentSlug: id,
|
|
1274
|
+
id,
|
|
1275
|
+
standalone: true
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
if (await fileExists(projectsDir2)) {
|
|
1279
|
+
try {
|
|
1280
|
+
const projects = await readdir3(projectsDir2, { withFileTypes: true });
|
|
1281
|
+
for (const p of projects) {
|
|
1282
|
+
if (!p.isDirectory()) continue;
|
|
1283
|
+
if (p.name.startsWith(".") || p.name.startsWith("_")) continue;
|
|
1284
|
+
const assignmentsPath = resolve8(projectsDir2, p.name, "assignments");
|
|
1285
|
+
if (!await fileExists(assignmentsPath)) continue;
|
|
1286
|
+
const entries = await readdir3(assignmentsPath, { withFileTypes: true });
|
|
1287
|
+
for (const a of entries) {
|
|
1288
|
+
if (!a.isDirectory()) continue;
|
|
1289
|
+
const aPath = resolve8(assignmentsPath, a.name, "assignment.md");
|
|
1290
|
+
if (!await fileExists(aPath)) continue;
|
|
1291
|
+
try {
|
|
1292
|
+
const content = await readFile5(aPath, "utf-8");
|
|
1293
|
+
const [fm] = extractFrontmatter(content);
|
|
1294
|
+
const fileId = getField(fm, "id");
|
|
1295
|
+
if (fileId === id) {
|
|
1296
|
+
projectMatch = {
|
|
1297
|
+
assignmentDir: resolve8(assignmentsPath, a.name),
|
|
1298
|
+
projectSlug: p.name,
|
|
1299
|
+
assignmentSlug: a.name,
|
|
1300
|
+
id,
|
|
1301
|
+
standalone: false
|
|
1302
|
+
};
|
|
1303
|
+
break;
|
|
1304
|
+
}
|
|
1305
|
+
} catch {
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
if (projectMatch) break;
|
|
1309
|
+
}
|
|
1310
|
+
} catch {
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
if (standaloneMatch && projectMatch) {
|
|
1314
|
+
console.warn(
|
|
1315
|
+
`Duplicate assignment ID ${id} found in both standalone and project-nested locations; using standalone`
|
|
1316
|
+
);
|
|
1317
|
+
return standaloneMatch;
|
|
1318
|
+
}
|
|
1319
|
+
return standaloneMatch ?? projectMatch ?? null;
|
|
1320
|
+
}
|
|
1321
|
+
var init_assignment_resolver = __esm({
|
|
1322
|
+
"src/utils/assignment-resolver.ts"() {
|
|
1323
|
+
"use strict";
|
|
1324
|
+
init_fs();
|
|
1325
|
+
init_parser();
|
|
1326
|
+
}
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1209
1329
|
// src/dashboard/help.ts
|
|
1210
1330
|
async function buildStatusGuide() {
|
|
1211
1331
|
const config = await getStatusConfig();
|
|
@@ -1622,8 +1742,8 @@ var init_help = __esm({
|
|
|
1622
1742
|
});
|
|
1623
1743
|
|
|
1624
1744
|
// src/dashboard/servers.ts
|
|
1625
|
-
import { readdir as
|
|
1626
|
-
import { resolve as
|
|
1745
|
+
import { readdir as readdir4, readFile as readFile6, unlink } from "fs/promises";
|
|
1746
|
+
import { resolve as resolve9 } from "path";
|
|
1627
1747
|
function sanitizeSessionName(name) {
|
|
1628
1748
|
return name.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
1629
1749
|
}
|
|
@@ -1671,18 +1791,18 @@ async function registerSession(dir, rawName) {
|
|
|
1671
1791
|
lastRefreshed: now,
|
|
1672
1792
|
overrides: {}
|
|
1673
1793
|
});
|
|
1674
|
-
await writeFileForce(
|
|
1794
|
+
await writeFileForce(resolve9(dir, `${name}.md`), content);
|
|
1675
1795
|
return name;
|
|
1676
1796
|
}
|
|
1677
1797
|
async function listSessionFiles(dir) {
|
|
1678
1798
|
if (!await fileExists(dir)) return [];
|
|
1679
|
-
const entries = await
|
|
1799
|
+
const entries = await readdir4(dir);
|
|
1680
1800
|
return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
|
|
1681
1801
|
}
|
|
1682
1802
|
async function readSessionFile(dir, name) {
|
|
1683
|
-
const filePath =
|
|
1803
|
+
const filePath = resolve9(dir, `${sanitizeSessionName(name)}.md`);
|
|
1684
1804
|
if (!await fileExists(filePath)) return null;
|
|
1685
|
-
const raw = await
|
|
1805
|
+
const raw = await readFile6(filePath, "utf-8");
|
|
1686
1806
|
const [frontmatter] = extractFrontmatter(raw);
|
|
1687
1807
|
if (!frontmatter) return null;
|
|
1688
1808
|
const session = getField(frontmatter, "session") ?? name;
|
|
@@ -1722,7 +1842,7 @@ async function readSessionFile(dir, name) {
|
|
|
1722
1842
|
};
|
|
1723
1843
|
}
|
|
1724
1844
|
async function removeSession(dir, name) {
|
|
1725
|
-
const filePath =
|
|
1845
|
+
const filePath = resolve9(dir, `${sanitizeSessionName(name)}.md`);
|
|
1726
1846
|
if (await fileExists(filePath)) {
|
|
1727
1847
|
await unlink(filePath);
|
|
1728
1848
|
}
|
|
@@ -1731,7 +1851,7 @@ async function updateLastRefreshed(dir, name) {
|
|
|
1731
1851
|
const data = await readSessionFile(dir, name);
|
|
1732
1852
|
if (!data) return;
|
|
1733
1853
|
const content = buildSessionContent({ ...data, lastRefreshed: nowTimestamp2() });
|
|
1734
|
-
await writeFileForce(
|
|
1854
|
+
await writeFileForce(resolve9(dir, `${sanitizeSessionName(name)}.md`), content);
|
|
1735
1855
|
}
|
|
1736
1856
|
async function setOverride(dir, sessionName, windowIndex, paneIndex, assignment) {
|
|
1737
1857
|
const data = await readSessionFile(dir, sessionName);
|
|
@@ -1743,7 +1863,7 @@ async function setOverride(dir, sessionName, windowIndex, paneIndex, assignment)
|
|
|
1743
1863
|
delete data.overrides[key];
|
|
1744
1864
|
}
|
|
1745
1865
|
const content = buildSessionContent({ ...data });
|
|
1746
|
-
await writeFileForce(
|
|
1866
|
+
await writeFileForce(resolve9(dir, `${sanitizeSessionName(sessionName)}.md`), content);
|
|
1747
1867
|
}
|
|
1748
1868
|
async function registerAutoSession(dir, rawName, opts) {
|
|
1749
1869
|
const name = sanitizeSessionName(rawName);
|
|
@@ -1760,7 +1880,7 @@ async function registerAutoSession(dir, rawName, opts) {
|
|
|
1760
1880
|
ports: opts.ports,
|
|
1761
1881
|
cwd: opts.cwd
|
|
1762
1882
|
});
|
|
1763
|
-
await writeFileForce(
|
|
1883
|
+
await writeFileForce(resolve9(dir, `${name}.md`), content);
|
|
1764
1884
|
return name;
|
|
1765
1885
|
}
|
|
1766
1886
|
var init_servers = __esm({
|
|
@@ -1792,8 +1912,8 @@ __export(scanner_exports, {
|
|
|
1792
1912
|
});
|
|
1793
1913
|
import { execFile } from "child_process";
|
|
1794
1914
|
import { promisify } from "util";
|
|
1795
|
-
import { resolve as
|
|
1796
|
-
import { realpath, readdir as
|
|
1915
|
+
import { resolve as resolve10 } from "path";
|
|
1916
|
+
import { realpath, readdir as readdir5, readFile as readFile7 } from "fs/promises";
|
|
1797
1917
|
function clearScanCache() {
|
|
1798
1918
|
cache = null;
|
|
1799
1919
|
}
|
|
@@ -1888,8 +2008,8 @@ async function getGitInfo(cwd) {
|
|
|
1888
2008
|
let isWorktree = false;
|
|
1889
2009
|
if (commonDir && gitDir && commonDir !== gitDir) {
|
|
1890
2010
|
try {
|
|
1891
|
-
const resolvedCommon = await realpath(
|
|
1892
|
-
const resolvedGit = await realpath(
|
|
2011
|
+
const resolvedCommon = await realpath(resolve10(cwd, commonDir));
|
|
2012
|
+
const resolvedGit = await realpath(resolve10(cwd, gitDir));
|
|
1893
2013
|
isWorktree = resolvedCommon !== resolvedGit;
|
|
1894
2014
|
} catch {
|
|
1895
2015
|
isWorktree = false;
|
|
@@ -1897,22 +2017,22 @@ async function getGitInfo(cwd) {
|
|
|
1897
2017
|
}
|
|
1898
2018
|
return { branch: branch || null, worktree: isWorktree };
|
|
1899
2019
|
}
|
|
1900
|
-
async function loadWorkspaceRecords(projectsDir2) {
|
|
2020
|
+
async function loadWorkspaceRecords(projectsDir2, assignmentsDir2) {
|
|
1901
2021
|
const records = [];
|
|
1902
2022
|
try {
|
|
1903
2023
|
const projects = await listProjects(projectsDir2);
|
|
1904
2024
|
for (const project of projects) {
|
|
1905
|
-
const
|
|
2025
|
+
const projectAssignmentsDir = resolve10(projectsDir2, project.slug, "assignments");
|
|
1906
2026
|
let slugs;
|
|
1907
2027
|
try {
|
|
1908
|
-
slugs = await
|
|
2028
|
+
slugs = await readdir5(projectAssignmentsDir);
|
|
1909
2029
|
} catch {
|
|
1910
2030
|
continue;
|
|
1911
2031
|
}
|
|
1912
2032
|
for (const aslug of slugs) {
|
|
1913
|
-
const aFile =
|
|
2033
|
+
const aFile = resolve10(projectAssignmentsDir, aslug, "assignment.md");
|
|
1914
2034
|
try {
|
|
1915
|
-
const raw = await
|
|
2035
|
+
const raw = await readFile7(aFile, "utf-8");
|
|
1916
2036
|
const [fm] = extractFrontmatter(raw);
|
|
1917
2037
|
if (!fm) continue;
|
|
1918
2038
|
records.push({
|
|
@@ -1929,6 +2049,30 @@ async function loadWorkspaceRecords(projectsDir2) {
|
|
|
1929
2049
|
}
|
|
1930
2050
|
} catch {
|
|
1931
2051
|
}
|
|
2052
|
+
if (assignmentsDir2) {
|
|
2053
|
+
try {
|
|
2054
|
+
const entries = await readdir5(assignmentsDir2);
|
|
2055
|
+
for (const id of entries) {
|
|
2056
|
+
if (id.startsWith(".") || id.startsWith("_")) continue;
|
|
2057
|
+
const aFile = resolve10(assignmentsDir2, id, "assignment.md");
|
|
2058
|
+
try {
|
|
2059
|
+
const raw = await readFile7(aFile, "utf-8");
|
|
2060
|
+
const [fm] = extractFrontmatter(raw);
|
|
2061
|
+
if (!fm) continue;
|
|
2062
|
+
records.push({
|
|
2063
|
+
projectSlug: null,
|
|
2064
|
+
assignmentSlug: id,
|
|
2065
|
+
assignmentTitle: getField(fm, "title") ?? id,
|
|
2066
|
+
worktreePath: getNestedField(fm, "workspace", "worktreePath") ?? null,
|
|
2067
|
+
branch: getNestedField(fm, "workspace", "branch") ?? null
|
|
2068
|
+
});
|
|
2069
|
+
} catch {
|
|
2070
|
+
continue;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
} catch {
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
1932
2076
|
return records;
|
|
1933
2077
|
}
|
|
1934
2078
|
async function resolveAndNormalize(p) {
|
|
@@ -2121,7 +2265,7 @@ async function scanAllSessions(serversDir2, projectsDir2, options) {
|
|
|
2121
2265
|
const tmuxAvailable = await checkTmuxAvailable();
|
|
2122
2266
|
const names = await listSessionFiles(serversDir2);
|
|
2123
2267
|
const lsofOutput = await getLsofOutput();
|
|
2124
|
-
const workspaceRecords = await loadWorkspaceRecords(projectsDir2);
|
|
2268
|
+
const workspaceRecords = await loadWorkspaceRecords(projectsDir2, options?.assignmentsDir);
|
|
2125
2269
|
const sessions = [];
|
|
2126
2270
|
for (const name of names) {
|
|
2127
2271
|
const data = await readSessionFile(serversDir2, name);
|
|
@@ -2136,11 +2280,11 @@ async function scanAllSessions(serversDir2, projectsDir2, options) {
|
|
|
2136
2280
|
cache = { data: result, expiry: Date.now() + CACHE_TTL_MS };
|
|
2137
2281
|
return result;
|
|
2138
2282
|
}
|
|
2139
|
-
async function scanSingleSession(serversDir2, projectsDir2, name) {
|
|
2283
|
+
async function scanSingleSession(serversDir2, projectsDir2, name, options) {
|
|
2140
2284
|
const data = await readSessionFile(serversDir2, name);
|
|
2141
2285
|
if (!data) return null;
|
|
2142
2286
|
const lsofOutput = await getLsofOutput();
|
|
2143
|
-
const workspaceRecords = await loadWorkspaceRecords(projectsDir2);
|
|
2287
|
+
const workspaceRecords = await loadWorkspaceRecords(projectsDir2, options?.assignmentsDir);
|
|
2144
2288
|
if (data.kind === "process") {
|
|
2145
2289
|
return scanProcessSession(data, lsofOutput, workspaceRecords);
|
|
2146
2290
|
}
|
|
@@ -2160,8 +2304,28 @@ var init_scanner = __esm({
|
|
|
2160
2304
|
});
|
|
2161
2305
|
|
|
2162
2306
|
// src/dashboard/api.ts
|
|
2163
|
-
import { readdir as
|
|
2164
|
-
import { resolve as
|
|
2307
|
+
import { readdir as readdir6, readFile as readFile8, writeFile as writeFile2 } from "fs/promises";
|
|
2308
|
+
import { resolve as resolve11, dirname as dirname3 } from "path";
|
|
2309
|
+
async function listStandaloneRecords(assignmentsDir2) {
|
|
2310
|
+
if (!assignmentsDir2) return [];
|
|
2311
|
+
if (!await fileExists(assignmentsDir2)) return [];
|
|
2312
|
+
const entries = await readdir6(assignmentsDir2, { withFileTypes: true });
|
|
2313
|
+
const records = [];
|
|
2314
|
+
for (const entry of entries) {
|
|
2315
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
|
|
2316
|
+
const assignmentDir = resolve11(assignmentsDir2, entry.name);
|
|
2317
|
+
const assignmentMdPath = resolve11(assignmentDir, "assignment.md");
|
|
2318
|
+
if (!await fileExists(assignmentMdPath)) continue;
|
|
2319
|
+
try {
|
|
2320
|
+
const content = await readFile8(assignmentMdPath, "utf-8");
|
|
2321
|
+
const record = parseAssignmentFull(content);
|
|
2322
|
+
records.push({ assignmentDir, id: entry.name, record });
|
|
2323
|
+
} catch {
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
records.sort((left, right) => compareTimestamps(right.record.updated, left.record.updated));
|
|
2327
|
+
return records;
|
|
2328
|
+
}
|
|
2165
2329
|
function toTitleCase(s) {
|
|
2166
2330
|
return s.replace(/_/g, " ").replace(/\b\w/g, (c2) => c2.toUpperCase());
|
|
2167
2331
|
}
|
|
@@ -2222,9 +2386,9 @@ async function listProjects(projectsDir2) {
|
|
|
2222
2386
|
return projectRecords.map((record) => record.summary);
|
|
2223
2387
|
}
|
|
2224
2388
|
async function readWorkspaceRegistry(projectsDir2) {
|
|
2225
|
-
const registryPath =
|
|
2389
|
+
const registryPath = resolve11(dirname3(projectsDir2), "workspaces.json");
|
|
2226
2390
|
try {
|
|
2227
|
-
const raw = await
|
|
2391
|
+
const raw = await readFile8(registryPath, "utf-8");
|
|
2228
2392
|
const parsed = JSON.parse(raw);
|
|
2229
2393
|
return Array.isArray(parsed) ? parsed.filter((w) => typeof w === "string") : [];
|
|
2230
2394
|
} catch {
|
|
@@ -2232,7 +2396,7 @@ async function readWorkspaceRegistry(projectsDir2) {
|
|
|
2232
2396
|
}
|
|
2233
2397
|
}
|
|
2234
2398
|
async function writeWorkspaceRegistry(projectsDir2, workspaces) {
|
|
2235
|
-
const registryPath =
|
|
2399
|
+
const registryPath = resolve11(dirname3(projectsDir2), "workspaces.json");
|
|
2236
2400
|
await writeFile2(registryPath, JSON.stringify(workspaces, null, 2) + "\n", "utf-8");
|
|
2237
2401
|
}
|
|
2238
2402
|
async function listWorkspaces(projectsDir2) {
|
|
@@ -2265,15 +2429,16 @@ async function deleteWorkspace(projectsDir2, name) {
|
|
|
2265
2429
|
const filtered = registered.filter((w) => w !== name);
|
|
2266
2430
|
await writeWorkspaceRegistry(projectsDir2, filtered);
|
|
2267
2431
|
}
|
|
2268
|
-
async function getOverview(projectsDir2, serversDir2) {
|
|
2432
|
+
async function getOverview(projectsDir2, serversDir2, assignmentsDir2) {
|
|
2269
2433
|
const projectRecords = await listProjectRecords(projectsDir2);
|
|
2270
|
-
const
|
|
2271
|
-
const
|
|
2434
|
+
const standaloneRecords = await listStandaloneRecords(assignmentsDir2);
|
|
2435
|
+
const attention = buildAttentionItems(projectRecords, standaloneRecords);
|
|
2436
|
+
const recentActivity = buildRecentActivity(projectRecords, standaloneRecords);
|
|
2272
2437
|
let serverStats;
|
|
2273
2438
|
if (serversDir2) {
|
|
2274
2439
|
try {
|
|
2275
2440
|
const { scanAllSessions: scanAllSessions2 } = await Promise.resolve().then(() => (init_scanner(), scanner_exports));
|
|
2276
|
-
const servers = await scanAllSessions2(serversDir2, projectsDir2);
|
|
2441
|
+
const servers = await scanAllSessions2(serversDir2, projectsDir2, { assignmentsDir: assignmentsDir2 });
|
|
2277
2442
|
if (servers.tmuxAvailable) {
|
|
2278
2443
|
const alive = servers.sessions.filter((s) => s.alive).length;
|
|
2279
2444
|
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);
|
|
@@ -2289,7 +2454,7 @@ async function getOverview(projectsDir2, serversDir2) {
|
|
|
2289
2454
|
}
|
|
2290
2455
|
return {
|
|
2291
2456
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2292
|
-
firstRun: projectRecords.length === 0,
|
|
2457
|
+
firstRun: projectRecords.length === 0 && standaloneRecords.length === 0,
|
|
2293
2458
|
stats: {
|
|
2294
2459
|
activeProjects: projectRecords.filter((record) => record.summary.status === "active").length,
|
|
2295
2460
|
inProgressAssignments: projectRecords.reduce(
|
|
@@ -2311,7 +2476,7 @@ async function getOverview(projectsDir2, serversDir2) {
|
|
|
2311
2476
|
staleAssignments: projectRecords.reduce(
|
|
2312
2477
|
(total, record) => total + record.assignments.filter((assignment) => isStale(assignment.updated)).length,
|
|
2313
2478
|
0
|
|
2314
|
-
)
|
|
2479
|
+
) + standaloneRecords.filter((sr) => isStale(sr.record.updated)).length
|
|
2315
2480
|
},
|
|
2316
2481
|
attention: attention.slice(0, OVERVIEW_ATTENTION_LIMIT),
|
|
2317
2482
|
recentProjects: projectRecords.map((record) => record.summary).sort((left, right) => compareTimestamps(right.updated, left.updated)).slice(0, RECENT_PROJECTS_LIMIT),
|
|
@@ -2319,13 +2484,14 @@ async function getOverview(projectsDir2, serversDir2) {
|
|
|
2319
2484
|
serverStats
|
|
2320
2485
|
};
|
|
2321
2486
|
}
|
|
2322
|
-
async function getAttention(projectsDir2, serversDir2) {
|
|
2487
|
+
async function getAttention(projectsDir2, serversDir2, assignmentsDir2) {
|
|
2323
2488
|
const projectRecords = await listProjectRecords(projectsDir2);
|
|
2324
|
-
const
|
|
2489
|
+
const standaloneRecords = await listStandaloneRecords(assignmentsDir2);
|
|
2490
|
+
const items = buildAttentionItems(projectRecords, standaloneRecords);
|
|
2325
2491
|
if (serversDir2) {
|
|
2326
2492
|
try {
|
|
2327
2493
|
const { scanAllSessions: scanAllSessions2 } = await Promise.resolve().then(() => (init_scanner(), scanner_exports));
|
|
2328
|
-
const servers = await scanAllSessions2(serversDir2, projectsDir2);
|
|
2494
|
+
const servers = await scanAllSessions2(serversDir2, projectsDir2, { assignmentsDir: assignmentsDir2 });
|
|
2329
2495
|
for (const session of servers.sessions) {
|
|
2330
2496
|
if (!session.alive) {
|
|
2331
2497
|
items.push({
|
|
@@ -2369,9 +2535,9 @@ async function getAttention(projectsDir2, serversDir2) {
|
|
|
2369
2535
|
items: pagedItems
|
|
2370
2536
|
};
|
|
2371
2537
|
}
|
|
2372
|
-
async function listAssignmentsBoard(projectsDir2) {
|
|
2538
|
+
async function listAssignmentsBoard(projectsDir2, assignmentsDir2) {
|
|
2373
2539
|
const projectRecords = await listProjectRecords(projectsDir2);
|
|
2374
|
-
const
|
|
2540
|
+
const projectItems = await Promise.all(
|
|
2375
2541
|
projectRecords.flatMap(
|
|
2376
2542
|
async (record) => Promise.all(
|
|
2377
2543
|
record.assignments.map(
|
|
@@ -2380,11 +2546,48 @@ async function listAssignmentsBoard(projectsDir2) {
|
|
|
2380
2546
|
)
|
|
2381
2547
|
)
|
|
2382
2548
|
);
|
|
2549
|
+
const standaloneRecords = await listStandaloneRecords(assignmentsDir2);
|
|
2550
|
+
const standaloneItems = await Promise.all(
|
|
2551
|
+
standaloneRecords.map(async (sr) => toStandaloneBoardItem(sr))
|
|
2552
|
+
);
|
|
2383
2553
|
return {
|
|
2384
2554
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2385
|
-
assignments:
|
|
2555
|
+
assignments: [...projectItems.flat(), ...standaloneItems].sort((left, right) => compareTimestamps(right.updated, left.updated))
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
2558
|
+
async function toStandaloneBoardItem(sr) {
|
|
2559
|
+
return {
|
|
2560
|
+
...toAssignmentSummary(sr.record),
|
|
2561
|
+
projectSlug: null,
|
|
2562
|
+
projectTitle: null,
|
|
2563
|
+
blockedReason: sr.record.blockedReason,
|
|
2564
|
+
projectWorkspace: null,
|
|
2565
|
+
availableTransitions: await getStandaloneAvailableTransitions(sr.record)
|
|
2386
2566
|
};
|
|
2387
2567
|
}
|
|
2568
|
+
async function getStandaloneAvailableTransitions(assignment) {
|
|
2569
|
+
const config = await getStatusConfig();
|
|
2570
|
+
const transitionDefs = getTransitionDefinitions(config);
|
|
2571
|
+
const actions = [];
|
|
2572
|
+
for (const definition of transitionDefs) {
|
|
2573
|
+
let warning = null;
|
|
2574
|
+
if (definition.command === "start" && !assignment.assignee) {
|
|
2575
|
+
warning = "No assignee set \u2014 consider assigning before starting.";
|
|
2576
|
+
}
|
|
2577
|
+
const target = getTargetStatus(assignment.status, definition.command, config.transitionTable);
|
|
2578
|
+
actions.push({
|
|
2579
|
+
command: definition.command,
|
|
2580
|
+
label: definition.label,
|
|
2581
|
+
description: definition.description,
|
|
2582
|
+
targetStatus: target ?? definition.command,
|
|
2583
|
+
disabled: false,
|
|
2584
|
+
disabledReason: null,
|
|
2585
|
+
warning,
|
|
2586
|
+
requiresReason: definition.requiresReason
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
2589
|
+
return actions;
|
|
2590
|
+
}
|
|
2388
2591
|
async function getHelp() {
|
|
2389
2592
|
return getDashboardHelp();
|
|
2390
2593
|
}
|
|
@@ -2393,7 +2596,7 @@ async function getEditableDocument(projectsDir2, documentType, projectSlug, assi
|
|
|
2393
2596
|
if (!filePath || !await fileExists(filePath)) {
|
|
2394
2597
|
return null;
|
|
2395
2598
|
}
|
|
2396
|
-
const content = await
|
|
2599
|
+
const content = await readFile8(filePath, "utf-8");
|
|
2397
2600
|
const title = getEditableDocumentTitle(documentType, projectSlug, assignmentSlug);
|
|
2398
2601
|
return {
|
|
2399
2602
|
documentType,
|
|
@@ -2404,16 +2607,44 @@ async function getEditableDocument(projectsDir2, documentType, projectSlug, assi
|
|
|
2404
2607
|
appendOnly: documentType === "handoff" || documentType === "decision-record"
|
|
2405
2608
|
};
|
|
2406
2609
|
}
|
|
2610
|
+
async function getEditableDocumentById(projectsDir2, assignmentsDir2, documentType, id) {
|
|
2611
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, id);
|
|
2612
|
+
if (!resolved) return null;
|
|
2613
|
+
if (!resolved.standalone && resolved.projectSlug) {
|
|
2614
|
+
return getEditableDocument(
|
|
2615
|
+
projectsDir2,
|
|
2616
|
+
documentType,
|
|
2617
|
+
resolved.projectSlug,
|
|
2618
|
+
resolved.assignmentSlug
|
|
2619
|
+
);
|
|
2620
|
+
}
|
|
2621
|
+
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;
|
|
2622
|
+
if (!fileName) return null;
|
|
2623
|
+
const filePath = resolve11(resolved.assignmentDir, fileName);
|
|
2624
|
+
if (!await fileExists(filePath)) return null;
|
|
2625
|
+
const content = await readFile8(filePath, "utf-8");
|
|
2626
|
+
const label = resolved.id;
|
|
2627
|
+
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}`;
|
|
2628
|
+
return {
|
|
2629
|
+
documentType,
|
|
2630
|
+
title,
|
|
2631
|
+
content,
|
|
2632
|
+
projectSlug: null,
|
|
2633
|
+
assignmentSlug: void 0,
|
|
2634
|
+
assignmentId: resolved.id,
|
|
2635
|
+
appendOnly: documentType === "handoff" || documentType === "decision-record"
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2407
2638
|
async function getProjectDetail(projectsDir2, slug) {
|
|
2408
|
-
const projectPath =
|
|
2409
|
-
const projectMdPath =
|
|
2639
|
+
const projectPath = resolve11(projectsDir2, slug);
|
|
2640
|
+
const projectMdPath = resolve11(projectPath, "project.md");
|
|
2410
2641
|
if (!await fileExists(projectMdPath)) {
|
|
2411
2642
|
return null;
|
|
2412
2643
|
}
|
|
2413
|
-
const projectContent = await
|
|
2644
|
+
const projectContent = await readFile8(projectMdPath, "utf-8");
|
|
2414
2645
|
const project = parseProject(projectContent);
|
|
2415
2646
|
const assignments = await listAssignmentRecords(projectPath);
|
|
2416
|
-
const rollup = buildProjectRollup(project, assignments);
|
|
2647
|
+
const rollup = await buildProjectRollup(projectPath, project, assignments);
|
|
2417
2648
|
const dependencyGraph = await loadDependencyGraph(projectPath, assignments);
|
|
2418
2649
|
const resources = await listResources(projectPath);
|
|
2419
2650
|
const memories = await listMemories(projectPath);
|
|
@@ -2440,17 +2671,17 @@ async function getProjectDetail(projectsDir2, slug) {
|
|
|
2440
2671
|
};
|
|
2441
2672
|
}
|
|
2442
2673
|
async function getAssignmentDetail(projectsDir2, projectSlug, assignmentSlug) {
|
|
2443
|
-
const assignmentDir =
|
|
2444
|
-
const assignmentMdPath =
|
|
2674
|
+
const assignmentDir = resolve11(projectsDir2, projectSlug, "assignments", assignmentSlug);
|
|
2675
|
+
const assignmentMdPath = resolve11(assignmentDir, "assignment.md");
|
|
2445
2676
|
if (!await fileExists(assignmentMdPath)) {
|
|
2446
2677
|
return null;
|
|
2447
2678
|
}
|
|
2448
|
-
const assignmentContent = await
|
|
2679
|
+
const assignmentContent = await readFile8(assignmentMdPath, "utf-8");
|
|
2449
2680
|
const assignment = parseAssignmentFull(assignmentContent);
|
|
2450
2681
|
let plan = null;
|
|
2451
|
-
const planPath =
|
|
2682
|
+
const planPath = resolve11(assignmentDir, "plan.md");
|
|
2452
2683
|
if (await fileExists(planPath)) {
|
|
2453
|
-
const planContent = await
|
|
2684
|
+
const planContent = await readFile8(planPath, "utf-8");
|
|
2454
2685
|
const parsed = parsePlan(planContent);
|
|
2455
2686
|
plan = {
|
|
2456
2687
|
status: parsed.status,
|
|
@@ -2459,9 +2690,9 @@ async function getAssignmentDetail(projectsDir2, projectSlug, assignmentSlug) {
|
|
|
2459
2690
|
};
|
|
2460
2691
|
}
|
|
2461
2692
|
let scratchpad = null;
|
|
2462
|
-
const scratchpadPath =
|
|
2693
|
+
const scratchpadPath = resolve11(assignmentDir, "scratchpad.md");
|
|
2463
2694
|
if (await fileExists(scratchpadPath)) {
|
|
2464
|
-
const scratchpadContent = await
|
|
2695
|
+
const scratchpadContent = await readFile8(scratchpadPath, "utf-8");
|
|
2465
2696
|
const parsed = parseScratchpad(scratchpadContent);
|
|
2466
2697
|
scratchpad = {
|
|
2467
2698
|
updated: parsed.updated,
|
|
@@ -2469,9 +2700,9 @@ async function getAssignmentDetail(projectsDir2, projectSlug, assignmentSlug) {
|
|
|
2469
2700
|
};
|
|
2470
2701
|
}
|
|
2471
2702
|
let handoff = null;
|
|
2472
|
-
const handoffPath =
|
|
2703
|
+
const handoffPath = resolve11(assignmentDir, "handoff.md");
|
|
2473
2704
|
if (await fileExists(handoffPath)) {
|
|
2474
|
-
const handoffContent = await
|
|
2705
|
+
const handoffContent = await readFile8(handoffPath, "utf-8");
|
|
2475
2706
|
const parsed = parseHandoff(handoffContent);
|
|
2476
2707
|
handoff = {
|
|
2477
2708
|
updated: parsed.updated,
|
|
@@ -2480,9 +2711,9 @@ async function getAssignmentDetail(projectsDir2, projectSlug, assignmentSlug) {
|
|
|
2480
2711
|
};
|
|
2481
2712
|
}
|
|
2482
2713
|
let decisionRecord = null;
|
|
2483
|
-
const decisionRecordPath =
|
|
2714
|
+
const decisionRecordPath = resolve11(assignmentDir, "decision-record.md");
|
|
2484
2715
|
if (await fileExists(decisionRecordPath)) {
|
|
2485
|
-
const decisionRecordContent = await
|
|
2716
|
+
const decisionRecordContent = await readFile8(decisionRecordPath, "utf-8");
|
|
2486
2717
|
const parsed = parseDecisionRecord(decisionRecordContent);
|
|
2487
2718
|
decisionRecord = {
|
|
2488
2719
|
updated: parsed.updated,
|
|
@@ -2490,6 +2721,28 @@ async function getAssignmentDetail(projectsDir2, projectSlug, assignmentSlug) {
|
|
|
2490
2721
|
body: parsed.body
|
|
2491
2722
|
};
|
|
2492
2723
|
}
|
|
2724
|
+
let progress = null;
|
|
2725
|
+
const progressPath = resolve11(assignmentDir, "progress.md");
|
|
2726
|
+
if (await fileExists(progressPath)) {
|
|
2727
|
+
const progressContent = await readFile8(progressPath, "utf-8");
|
|
2728
|
+
const parsed = parseProgress(progressContent);
|
|
2729
|
+
progress = {
|
|
2730
|
+
updated: parsed.updated,
|
|
2731
|
+
entryCount: parsed.entryCount,
|
|
2732
|
+
entries: parsed.entries
|
|
2733
|
+
};
|
|
2734
|
+
}
|
|
2735
|
+
let comments = null;
|
|
2736
|
+
const commentsPath = resolve11(assignmentDir, "comments.md");
|
|
2737
|
+
if (await fileExists(commentsPath)) {
|
|
2738
|
+
const commentsContent = await readFile8(commentsPath, "utf-8");
|
|
2739
|
+
const parsed = parseComments(commentsContent);
|
|
2740
|
+
comments = {
|
|
2741
|
+
updated: parsed.updated,
|
|
2742
|
+
entryCount: parsed.entryCount,
|
|
2743
|
+
entries: parsed.entries
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2493
2746
|
const detail = {
|
|
2494
2747
|
id: assignment.id,
|
|
2495
2748
|
projectSlug,
|
|
@@ -2513,6 +2766,9 @@ async function getAssignmentDetail(projectsDir2, projectSlug, assignmentSlug) {
|
|
|
2513
2766
|
scratchpad,
|
|
2514
2767
|
handoff,
|
|
2515
2768
|
decisionRecord,
|
|
2769
|
+
progress,
|
|
2770
|
+
comments,
|
|
2771
|
+
referencedBy: [],
|
|
2516
2772
|
availableTransitions: await getAvailableTransitions(
|
|
2517
2773
|
projectsDir2,
|
|
2518
2774
|
projectSlug,
|
|
@@ -2576,25 +2832,212 @@ async function getAssignmentDetail(projectsDir2, projectSlug, assignmentSlug) {
|
|
|
2576
2832
|
});
|
|
2577
2833
|
}
|
|
2578
2834
|
detail.enrichedLinks = enrichedLinks;
|
|
2835
|
+
detail.referencedBy = await computeReferencedBy(
|
|
2836
|
+
{ id: assignment.id, projectSlug, slug: detail.slug },
|
|
2837
|
+
projectsDir2,
|
|
2838
|
+
void 0
|
|
2839
|
+
);
|
|
2840
|
+
return detail;
|
|
2841
|
+
}
|
|
2842
|
+
async function computeReferencedBy(target, projectsDir2, assignmentsDir2) {
|
|
2843
|
+
const sources = [];
|
|
2844
|
+
const projectRecords = await listProjectRecords(projectsDir2);
|
|
2845
|
+
for (const rec of projectRecords) {
|
|
2846
|
+
for (const a of rec.assignments) {
|
|
2847
|
+
sources.push({
|
|
2848
|
+
id: a.id,
|
|
2849
|
+
slug: a.slug,
|
|
2850
|
+
title: a.title,
|
|
2851
|
+
projectSlug: rec.summary.slug,
|
|
2852
|
+
assignmentDir: resolve11(rec.projectPath, "assignments", a.slug)
|
|
2853
|
+
});
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
const standaloneRecords = await listStandaloneRecords(assignmentsDir2);
|
|
2857
|
+
for (const sr of standaloneRecords) {
|
|
2858
|
+
sources.push({
|
|
2859
|
+
id: sr.id,
|
|
2860
|
+
slug: sr.record.slug || sr.id,
|
|
2861
|
+
title: sr.record.title,
|
|
2862
|
+
projectSlug: null,
|
|
2863
|
+
assignmentDir: sr.assignmentDir
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2866
|
+
const references = [];
|
|
2867
|
+
for (const source of sources) {
|
|
2868
|
+
if (source.id === target.id) continue;
|
|
2869
|
+
const mentions = await countMentionsInAssignment(source.assignmentDir, target);
|
|
2870
|
+
if (mentions > 0) {
|
|
2871
|
+
references.push({
|
|
2872
|
+
sourceId: source.id,
|
|
2873
|
+
sourceSlug: source.slug,
|
|
2874
|
+
sourceTitle: source.title,
|
|
2875
|
+
sourceProjectSlug: source.projectSlug,
|
|
2876
|
+
mentions
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
if (references.length >= REFERENCED_BY_LIMIT) break;
|
|
2880
|
+
}
|
|
2881
|
+
return references.slice(0, REFERENCED_BY_LIMIT);
|
|
2882
|
+
}
|
|
2883
|
+
async function countMentionsInAssignment(sourceDir, target) {
|
|
2884
|
+
const bodies = [];
|
|
2885
|
+
const assignmentMd = resolve11(sourceDir, "assignment.md");
|
|
2886
|
+
if (await fileExists(assignmentMd)) {
|
|
2887
|
+
const content = await readFile8(assignmentMd, "utf-8");
|
|
2888
|
+
const todosMatch = content.match(/^## Todos\s*$([\s\S]*?)(?=^## |$(?![\r\n]))/m);
|
|
2889
|
+
if (todosMatch) bodies.push(todosMatch[1]);
|
|
2890
|
+
}
|
|
2891
|
+
for (const filename of ["progress.md", "comments.md", "handoff.md"]) {
|
|
2892
|
+
const path = resolve11(sourceDir, filename);
|
|
2893
|
+
if (await fileExists(path)) {
|
|
2894
|
+
try {
|
|
2895
|
+
bodies.push(await readFile8(path, "utf-8"));
|
|
2896
|
+
} catch {
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
let total = 0;
|
|
2901
|
+
const patterns = buildLinkPatternsForTarget(target);
|
|
2902
|
+
for (const body of bodies) {
|
|
2903
|
+
for (const pattern of patterns) {
|
|
2904
|
+
const matches = body.match(pattern);
|
|
2905
|
+
if (matches) total += matches.length;
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
return total;
|
|
2909
|
+
}
|
|
2910
|
+
function buildLinkPatternsForTarget(target) {
|
|
2911
|
+
const patterns = [];
|
|
2912
|
+
patterns.push(new RegExp(`/assignments/${escapeRegExpLocal(target.id)}(?:/|\\b)`, "g"));
|
|
2913
|
+
if (target.projectSlug) {
|
|
2914
|
+
patterns.push(
|
|
2915
|
+
new RegExp(
|
|
2916
|
+
`/projects/${escapeRegExpLocal(target.projectSlug)}/assignments/${escapeRegExpLocal(target.slug)}(?:/|\\b)`,
|
|
2917
|
+
"g"
|
|
2918
|
+
)
|
|
2919
|
+
);
|
|
2920
|
+
patterns.push(
|
|
2921
|
+
new RegExp(`\\.\\./${escapeRegExpLocal(target.slug)}(?:/|\\b)`, "g")
|
|
2922
|
+
);
|
|
2923
|
+
}
|
|
2924
|
+
return patterns;
|
|
2925
|
+
}
|
|
2926
|
+
function escapeRegExpLocal(value) {
|
|
2927
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2928
|
+
}
|
|
2929
|
+
async function getAssignmentDetailById(projectsDir2, assignmentsDir2, id) {
|
|
2930
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, id);
|
|
2931
|
+
if (!resolved) return null;
|
|
2932
|
+
if (!resolved.standalone && resolved.projectSlug) {
|
|
2933
|
+
const detail = await getAssignmentDetail(projectsDir2, resolved.projectSlug, resolved.assignmentSlug);
|
|
2934
|
+
if (!detail) return null;
|
|
2935
|
+
detail.referencedBy = await computeReferencedBy(
|
|
2936
|
+
{ id: detail.id, projectSlug: detail.projectSlug, slug: detail.slug },
|
|
2937
|
+
projectsDir2,
|
|
2938
|
+
assignmentsDir2
|
|
2939
|
+
);
|
|
2940
|
+
return detail;
|
|
2941
|
+
}
|
|
2942
|
+
const standaloneDetail = await buildStandaloneAssignmentDetail(resolved);
|
|
2943
|
+
if (!standaloneDetail) return null;
|
|
2944
|
+
standaloneDetail.referencedBy = await computeReferencedBy(
|
|
2945
|
+
{ id: standaloneDetail.id, projectSlug: null, slug: standaloneDetail.slug },
|
|
2946
|
+
projectsDir2,
|
|
2947
|
+
assignmentsDir2
|
|
2948
|
+
);
|
|
2949
|
+
return standaloneDetail;
|
|
2950
|
+
}
|
|
2951
|
+
async function buildStandaloneAssignmentDetail(resolved) {
|
|
2952
|
+
const assignmentDir = resolved.assignmentDir;
|
|
2953
|
+
const assignmentMdPath = resolve11(assignmentDir, "assignment.md");
|
|
2954
|
+
if (!await fileExists(assignmentMdPath)) return null;
|
|
2955
|
+
const assignmentContent = await readFile8(assignmentMdPath, "utf-8");
|
|
2956
|
+
const assignment = parseAssignmentFull(assignmentContent);
|
|
2957
|
+
let plan = null;
|
|
2958
|
+
const planPath = resolve11(assignmentDir, "plan.md");
|
|
2959
|
+
if (await fileExists(planPath)) {
|
|
2960
|
+
const parsed = parsePlan(await readFile8(planPath, "utf-8"));
|
|
2961
|
+
plan = { status: parsed.status, updated: parsed.updated, body: parsed.body };
|
|
2962
|
+
}
|
|
2963
|
+
let scratchpad = null;
|
|
2964
|
+
const scratchpadPath = resolve11(assignmentDir, "scratchpad.md");
|
|
2965
|
+
if (await fileExists(scratchpadPath)) {
|
|
2966
|
+
const parsed = parseScratchpad(await readFile8(scratchpadPath, "utf-8"));
|
|
2967
|
+
scratchpad = { updated: parsed.updated, body: parsed.body };
|
|
2968
|
+
}
|
|
2969
|
+
let handoff = null;
|
|
2970
|
+
const handoffPath = resolve11(assignmentDir, "handoff.md");
|
|
2971
|
+
if (await fileExists(handoffPath)) {
|
|
2972
|
+
const parsed = parseHandoff(await readFile8(handoffPath, "utf-8"));
|
|
2973
|
+
handoff = { updated: parsed.updated, handoffCount: parsed.handoffCount, body: parsed.body };
|
|
2974
|
+
}
|
|
2975
|
+
let decisionRecord = null;
|
|
2976
|
+
const decisionRecordPath = resolve11(assignmentDir, "decision-record.md");
|
|
2977
|
+
if (await fileExists(decisionRecordPath)) {
|
|
2978
|
+
const parsed = parseDecisionRecord(await readFile8(decisionRecordPath, "utf-8"));
|
|
2979
|
+
decisionRecord = { updated: parsed.updated, decisionCount: parsed.decisionCount, body: parsed.body };
|
|
2980
|
+
}
|
|
2981
|
+
let progress = null;
|
|
2982
|
+
const progressPath = resolve11(assignmentDir, "progress.md");
|
|
2983
|
+
if (await fileExists(progressPath)) {
|
|
2984
|
+
const parsed = parseProgress(await readFile8(progressPath, "utf-8"));
|
|
2985
|
+
progress = { updated: parsed.updated, entryCount: parsed.entryCount, entries: parsed.entries };
|
|
2986
|
+
}
|
|
2987
|
+
let comments = null;
|
|
2988
|
+
const commentsPath = resolve11(assignmentDir, "comments.md");
|
|
2989
|
+
if (await fileExists(commentsPath)) {
|
|
2990
|
+
const parsed = parseComments(await readFile8(commentsPath, "utf-8"));
|
|
2991
|
+
comments = { updated: parsed.updated, entryCount: parsed.entryCount, entries: parsed.entries };
|
|
2992
|
+
}
|
|
2993
|
+
const detail = {
|
|
2994
|
+
id: assignment.id,
|
|
2995
|
+
projectSlug: null,
|
|
2996
|
+
slug: assignment.slug || resolved.id,
|
|
2997
|
+
title: assignment.title,
|
|
2998
|
+
status: assignment.status,
|
|
2999
|
+
priority: assignment.priority,
|
|
3000
|
+
assignee: assignment.assignee,
|
|
3001
|
+
dependsOn: [],
|
|
3002
|
+
// standalone cannot declare dependencies
|
|
3003
|
+
links: [],
|
|
3004
|
+
reverseLinks: [],
|
|
3005
|
+
enrichedLinks: [],
|
|
3006
|
+
blockedReason: assignment.blockedReason,
|
|
3007
|
+
workspace: assignment.workspace,
|
|
3008
|
+
externalIds: assignment.externalIds,
|
|
3009
|
+
tags: assignment.tags,
|
|
3010
|
+
created: assignment.created,
|
|
3011
|
+
updated: assignment.updated,
|
|
3012
|
+
body: assignment.body,
|
|
3013
|
+
plan,
|
|
3014
|
+
scratchpad,
|
|
3015
|
+
handoff,
|
|
3016
|
+
decisionRecord,
|
|
3017
|
+
progress,
|
|
3018
|
+
comments,
|
|
3019
|
+
referencedBy: [],
|
|
3020
|
+
availableTransitions: await getStandaloneAvailableTransitions(assignment)
|
|
3021
|
+
};
|
|
2579
3022
|
return detail;
|
|
2580
3023
|
}
|
|
2581
3024
|
async function listProjectRecords(projectsDir2) {
|
|
2582
3025
|
if (!await fileExists(projectsDir2)) {
|
|
2583
3026
|
return [];
|
|
2584
3027
|
}
|
|
2585
|
-
const entries = await
|
|
3028
|
+
const entries = await readdir6(projectsDir2, { withFileTypes: true });
|
|
2586
3029
|
const projectDirs = entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."));
|
|
2587
3030
|
const records = [];
|
|
2588
3031
|
for (const entry of projectDirs) {
|
|
2589
|
-
const projectPath =
|
|
2590
|
-
const projectMdPath =
|
|
3032
|
+
const projectPath = resolve11(projectsDir2, entry.name);
|
|
3033
|
+
const projectMdPath = resolve11(projectPath, "project.md");
|
|
2591
3034
|
if (!await fileExists(projectMdPath)) {
|
|
2592
3035
|
continue;
|
|
2593
3036
|
}
|
|
2594
|
-
const projectContent = await
|
|
3037
|
+
const projectContent = await readFile8(projectMdPath, "utf-8");
|
|
2595
3038
|
const project = parseProject(projectContent);
|
|
2596
3039
|
const assignments = await listAssignmentRecords(projectPath);
|
|
2597
|
-
const rollup = buildProjectRollup(project, assignments);
|
|
3040
|
+
const rollup = await buildProjectRollup(projectPath, project, assignments);
|
|
2598
3041
|
const updated = getProjectActivityTimestamp(project.updated, assignments);
|
|
2599
3042
|
records.push({
|
|
2600
3043
|
projectPath,
|
|
@@ -2622,39 +3065,39 @@ async function listProjectRecords(projectsDir2) {
|
|
|
2622
3065
|
return records;
|
|
2623
3066
|
}
|
|
2624
3067
|
async function listAssignmentRecords(projectPath) {
|
|
2625
|
-
const assignmentsDir2 =
|
|
3068
|
+
const assignmentsDir2 = resolve11(projectPath, "assignments");
|
|
2626
3069
|
if (!await fileExists(assignmentsDir2)) {
|
|
2627
3070
|
return [];
|
|
2628
3071
|
}
|
|
2629
|
-
const entries = await
|
|
3072
|
+
const entries = await readdir6(assignmentsDir2, { withFileTypes: true });
|
|
2630
3073
|
const records = [];
|
|
2631
3074
|
for (const entry of entries) {
|
|
2632
3075
|
if (!entry.isDirectory()) {
|
|
2633
3076
|
continue;
|
|
2634
3077
|
}
|
|
2635
|
-
const assignmentMd =
|
|
3078
|
+
const assignmentMd = resolve11(assignmentsDir2, entry.name, "assignment.md");
|
|
2636
3079
|
if (!await fileExists(assignmentMd)) {
|
|
2637
3080
|
continue;
|
|
2638
3081
|
}
|
|
2639
|
-
const content = await
|
|
3082
|
+
const content = await readFile8(assignmentMd, "utf-8");
|
|
2640
3083
|
records.push(parseAssignmentFull(content));
|
|
2641
3084
|
}
|
|
2642
3085
|
records.sort((left, right) => compareTimestamps(right.updated, left.updated));
|
|
2643
3086
|
return records;
|
|
2644
3087
|
}
|
|
2645
3088
|
async function listResources(projectPath) {
|
|
2646
|
-
const resourcesDir =
|
|
3089
|
+
const resourcesDir = resolve11(projectPath, "resources");
|
|
2647
3090
|
if (!await fileExists(resourcesDir)) {
|
|
2648
3091
|
return [];
|
|
2649
3092
|
}
|
|
2650
|
-
const entries = await
|
|
3093
|
+
const entries = await readdir6(resourcesDir, { withFileTypes: true });
|
|
2651
3094
|
const results = [];
|
|
2652
3095
|
for (const entry of entries) {
|
|
2653
3096
|
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith("_")) {
|
|
2654
3097
|
continue;
|
|
2655
3098
|
}
|
|
2656
|
-
const filePath =
|
|
2657
|
-
const content = await
|
|
3099
|
+
const filePath = resolve11(resourcesDir, entry.name);
|
|
3100
|
+
const content = await readFile8(filePath, "utf-8");
|
|
2658
3101
|
const parsed = parseResource(content);
|
|
2659
3102
|
results.push({
|
|
2660
3103
|
name: parsed.name,
|
|
@@ -2669,18 +3112,18 @@ async function listResources(projectPath) {
|
|
|
2669
3112
|
return results;
|
|
2670
3113
|
}
|
|
2671
3114
|
async function listMemories(projectPath) {
|
|
2672
|
-
const memoriesDir =
|
|
3115
|
+
const memoriesDir = resolve11(projectPath, "memories");
|
|
2673
3116
|
if (!await fileExists(memoriesDir)) {
|
|
2674
3117
|
return [];
|
|
2675
3118
|
}
|
|
2676
|
-
const entries = await
|
|
3119
|
+
const entries = await readdir6(memoriesDir, { withFileTypes: true });
|
|
2677
3120
|
const results = [];
|
|
2678
3121
|
for (const entry of entries) {
|
|
2679
3122
|
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith("_")) {
|
|
2680
3123
|
continue;
|
|
2681
3124
|
}
|
|
2682
|
-
const filePath =
|
|
2683
|
-
const content = await
|
|
3125
|
+
const filePath = resolve11(memoriesDir, entry.name);
|
|
3126
|
+
const content = await readFile8(filePath, "utf-8");
|
|
2684
3127
|
const parsed = parseMemory(content);
|
|
2685
3128
|
results.push({
|
|
2686
3129
|
name: parsed.name,
|
|
@@ -2695,9 +3138,9 @@ async function listMemories(projectPath) {
|
|
|
2695
3138
|
return results;
|
|
2696
3139
|
}
|
|
2697
3140
|
async function loadDependencyGraph(projectPath, assignments) {
|
|
2698
|
-
const statusPath =
|
|
3141
|
+
const statusPath = resolve11(projectPath, "_status.md");
|
|
2699
3142
|
if (await fileExists(statusPath)) {
|
|
2700
|
-
const statusContent = await
|
|
3143
|
+
const statusContent = await readFile8(statusPath, "utf-8");
|
|
2701
3144
|
const parsed = parseStatus(statusContent);
|
|
2702
3145
|
const derivedGraph = extractMermaidGraph(parsed.body);
|
|
2703
3146
|
if (derivedGraph) {
|
|
@@ -2706,13 +3149,13 @@ async function loadDependencyGraph(projectPath, assignments) {
|
|
|
2706
3149
|
}
|
|
2707
3150
|
return buildDependencyGraph(assignments);
|
|
2708
3151
|
}
|
|
2709
|
-
function buildProjectRollup(project, assignments) {
|
|
3152
|
+
async function buildProjectRollup(projectPath, project, assignments) {
|
|
2710
3153
|
const progress = { total: assignments.length };
|
|
2711
3154
|
let openQuestions = 0;
|
|
2712
3155
|
for (const assignment of assignments) {
|
|
2713
3156
|
const s = assignment.status;
|
|
2714
3157
|
progress[s] = (progress[s] ?? 0) + 1;
|
|
2715
|
-
openQuestions +=
|
|
3158
|
+
openQuestions += await countOpenQuestions(projectPath, assignment.slug);
|
|
2716
3159
|
}
|
|
2717
3160
|
const needsAttention = {
|
|
2718
3161
|
blockedCount: progress["blocked"] ?? 0,
|
|
@@ -2797,7 +3240,7 @@ async function getAvailableTransitions(projectsDir2, projectSlug, assignmentSlug
|
|
|
2797
3240
|
const config = await getStatusConfig();
|
|
2798
3241
|
const transitionDefs = getTransitionDefinitions(config);
|
|
2799
3242
|
const actions = [];
|
|
2800
|
-
const projectPath =
|
|
3243
|
+
const projectPath = resolve11(projectsDir2, projectSlug);
|
|
2801
3244
|
for (const definition of transitionDefs) {
|
|
2802
3245
|
let warning = null;
|
|
2803
3246
|
if (definition.command === "start" && !assignment.assignee) {
|
|
@@ -2827,12 +3270,12 @@ async function getUnmetDependencies(projectPath, dependsOn, terminalStatuses3) {
|
|
|
2827
3270
|
const terminals = terminalStatuses3 ?? /* @__PURE__ */ new Set(["completed"]);
|
|
2828
3271
|
const unmet = [];
|
|
2829
3272
|
for (const dependency of dependsOn) {
|
|
2830
|
-
const dependencyPath =
|
|
3273
|
+
const dependencyPath = resolve11(projectPath, "assignments", dependency, "assignment.md");
|
|
2831
3274
|
if (!await fileExists(dependencyPath)) {
|
|
2832
3275
|
unmet.push(`${dependency} (missing)`);
|
|
2833
3276
|
continue;
|
|
2834
3277
|
}
|
|
2835
|
-
const content = await
|
|
3278
|
+
const content = await readFile8(dependencyPath, "utf-8");
|
|
2836
3279
|
const parsed = parseAssignmentFull(content);
|
|
2837
3280
|
if (!terminals.has(parsed.status)) {
|
|
2838
3281
|
unmet.push(`${dependency} (${parsed.status})`);
|
|
@@ -2840,7 +3283,7 @@ async function getUnmetDependencies(projectPath, dependsOn, terminalStatuses3) {
|
|
|
2840
3283
|
}
|
|
2841
3284
|
return unmet;
|
|
2842
3285
|
}
|
|
2843
|
-
function buildAttentionItems(projectRecords) {
|
|
3286
|
+
function buildAttentionItems(projectRecords, standaloneRecords = []) {
|
|
2844
3287
|
const items = [];
|
|
2845
3288
|
for (const record of projectRecords) {
|
|
2846
3289
|
for (const assignment of record.assignments) {
|
|
@@ -2890,9 +3333,36 @@ function buildAttentionItems(projectRecords) {
|
|
|
2890
3333
|
}
|
|
2891
3334
|
}
|
|
2892
3335
|
}
|
|
3336
|
+
for (const sr of standaloneRecords) {
|
|
3337
|
+
const assignment = sr.record;
|
|
3338
|
+
const stale = isStale(assignment.updated);
|
|
3339
|
+
const base = {
|
|
3340
|
+
projectSlug: null,
|
|
3341
|
+
projectTitle: null,
|
|
3342
|
+
assignmentSlug: assignment.slug || sr.id,
|
|
3343
|
+
assignmentTitle: assignment.title,
|
|
3344
|
+
status: assignment.status,
|
|
3345
|
+
updated: assignment.updated,
|
|
3346
|
+
href: `/assignments/${sr.id}`,
|
|
3347
|
+
blockedReason: assignment.blockedReason,
|
|
3348
|
+
stale
|
|
3349
|
+
};
|
|
3350
|
+
if (assignment.status === "failed") {
|
|
3351
|
+
items.push({ id: `standalone:${sr.id}:failed`, severity: "critical", reason: "Marked failed and needs a recovery decision.", ...base });
|
|
3352
|
+
}
|
|
3353
|
+
if (assignment.status === "blocked") {
|
|
3354
|
+
items.push({ id: `standalone:${sr.id}:blocked`, severity: "high", reason: assignment.blockedReason || "Blocked and waiting for intervention.", ...base });
|
|
3355
|
+
}
|
|
3356
|
+
if (assignment.status === "review") {
|
|
3357
|
+
items.push({ id: `standalone:${sr.id}:review`, severity: "medium", reason: "Ready for review.", ...base });
|
|
3358
|
+
}
|
|
3359
|
+
if (stale) {
|
|
3360
|
+
items.push({ id: `standalone:${sr.id}:stale`, severity: "low", reason: "No source updates have been recorded in the last 7 days.", ...base });
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
2893
3363
|
return items.sort(compareAttentionItems);
|
|
2894
3364
|
}
|
|
2895
|
-
function buildRecentActivity(projectRecords) {
|
|
3365
|
+
function buildRecentActivity(projectRecords, standaloneRecords = []) {
|
|
2896
3366
|
const activity = [];
|
|
2897
3367
|
for (const record of projectRecords) {
|
|
2898
3368
|
activity.push({
|
|
@@ -2920,6 +3390,20 @@ function buildRecentActivity(projectRecords) {
|
|
|
2920
3390
|
});
|
|
2921
3391
|
}
|
|
2922
3392
|
}
|
|
3393
|
+
for (const sr of standaloneRecords) {
|
|
3394
|
+
const assignment = sr.record;
|
|
3395
|
+
activity.push({
|
|
3396
|
+
id: `standalone-assignment:${sr.id}`,
|
|
3397
|
+
type: "assignment",
|
|
3398
|
+
title: assignment.title,
|
|
3399
|
+
updated: assignment.updated,
|
|
3400
|
+
href: `/assignments/${sr.id}`,
|
|
3401
|
+
projectSlug: null,
|
|
3402
|
+
projectTitle: null,
|
|
3403
|
+
assignmentSlug: assignment.slug || sr.id,
|
|
3404
|
+
summary: `Standalone assignment is ${assignment.status} with ${assignment.priority} priority.`
|
|
3405
|
+
});
|
|
3406
|
+
}
|
|
2923
3407
|
activity.sort((left, right) => compareTimestamps(right.updated, left.updated));
|
|
2924
3408
|
return activity;
|
|
2925
3409
|
}
|
|
@@ -2945,9 +3429,25 @@ function isStale(updated) {
|
|
|
2945
3429
|
}
|
|
2946
3430
|
return Date.now() - timestamp > STALE_ASSIGNMENT_MS;
|
|
2947
3431
|
}
|
|
2948
|
-
function
|
|
2949
|
-
const
|
|
2950
|
-
|
|
3432
|
+
async function countOpenQuestions(projectPath, assignmentSlug) {
|
|
3433
|
+
const commentsPath = resolve11(
|
|
3434
|
+
projectPath,
|
|
3435
|
+
"assignments",
|
|
3436
|
+
assignmentSlug,
|
|
3437
|
+
"comments.md"
|
|
3438
|
+
);
|
|
3439
|
+
if (!await fileExists(commentsPath)) {
|
|
3440
|
+
return 0;
|
|
3441
|
+
}
|
|
3442
|
+
try {
|
|
3443
|
+
const content = await readFile8(commentsPath, "utf-8");
|
|
3444
|
+
const parsed = parseComments(content);
|
|
3445
|
+
return parsed.entries.filter(
|
|
3446
|
+
(e) => e.type === "question" && e.resolved !== true
|
|
3447
|
+
).length;
|
|
3448
|
+
} catch {
|
|
3449
|
+
return 0;
|
|
3450
|
+
}
|
|
2951
3451
|
}
|
|
2952
3452
|
function getProjectActivityTimestamp(projectUpdated, assignments) {
|
|
2953
3453
|
let latest = projectUpdated;
|
|
@@ -2961,17 +3461,17 @@ function getProjectActivityTimestamp(projectUpdated, assignments) {
|
|
|
2961
3461
|
function getDocumentPath(projectsDir2, documentType, projectSlug, assignmentSlug) {
|
|
2962
3462
|
switch (documentType) {
|
|
2963
3463
|
case "project":
|
|
2964
|
-
return
|
|
3464
|
+
return resolve11(projectsDir2, projectSlug, "project.md");
|
|
2965
3465
|
case "assignment":
|
|
2966
|
-
return assignmentSlug ?
|
|
3466
|
+
return assignmentSlug ? resolve11(projectsDir2, projectSlug, "assignments", assignmentSlug, "assignment.md") : null;
|
|
2967
3467
|
case "plan":
|
|
2968
|
-
return assignmentSlug ?
|
|
3468
|
+
return assignmentSlug ? resolve11(projectsDir2, projectSlug, "assignments", assignmentSlug, "plan.md") : null;
|
|
2969
3469
|
case "scratchpad":
|
|
2970
|
-
return assignmentSlug ?
|
|
3470
|
+
return assignmentSlug ? resolve11(projectsDir2, projectSlug, "assignments", assignmentSlug, "scratchpad.md") : null;
|
|
2971
3471
|
case "handoff":
|
|
2972
|
-
return assignmentSlug ?
|
|
3472
|
+
return assignmentSlug ? resolve11(projectsDir2, projectSlug, "assignments", assignmentSlug, "handoff.md") : null;
|
|
2973
3473
|
case "decision-record":
|
|
2974
|
-
return assignmentSlug ?
|
|
3474
|
+
return assignmentSlug ? resolve11(projectsDir2, projectSlug, "assignments", assignmentSlug, "decision-record.md") : null;
|
|
2975
3475
|
default:
|
|
2976
3476
|
return null;
|
|
2977
3477
|
}
|
|
@@ -2998,12 +3498,12 @@ function getEditableDocumentTitle(documentType, projectSlug, assignmentSlug) {
|
|
|
2998
3498
|
}
|
|
2999
3499
|
async function listPlaybooks(playbooksDir3) {
|
|
3000
3500
|
if (!await fileExists(playbooksDir3)) return [];
|
|
3001
|
-
const entries = await
|
|
3501
|
+
const entries = await readdir6(playbooksDir3, { withFileTypes: true });
|
|
3002
3502
|
const playbooks = [];
|
|
3003
3503
|
for (const entry of entries) {
|
|
3004
3504
|
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith("_") || entry.name === "manifest.md") continue;
|
|
3005
|
-
const filePath =
|
|
3006
|
-
const raw = await
|
|
3505
|
+
const filePath = resolve11(playbooksDir3, entry.name);
|
|
3506
|
+
const raw = await readFile8(filePath, "utf-8");
|
|
3007
3507
|
const parsed = parsePlaybook(raw);
|
|
3008
3508
|
const slug = parsed.slug || entry.name.replace(/\.md$/, "");
|
|
3009
3509
|
playbooks.push({
|
|
@@ -3019,9 +3519,9 @@ async function listPlaybooks(playbooksDir3) {
|
|
|
3019
3519
|
return playbooks.sort((a, b) => (b.updated || b.created).localeCompare(a.updated || a.created));
|
|
3020
3520
|
}
|
|
3021
3521
|
async function getPlaybookDetail(playbooksDir3, slug) {
|
|
3022
|
-
const filePath =
|
|
3522
|
+
const filePath = resolve11(playbooksDir3, `${slug}.md`);
|
|
3023
3523
|
if (!await fileExists(filePath)) return null;
|
|
3024
|
-
const raw = await
|
|
3524
|
+
const raw = await readFile8(filePath, "utf-8");
|
|
3025
3525
|
const parsed = parsePlaybook(raw);
|
|
3026
3526
|
return {
|
|
3027
3527
|
slug: parsed.slug || slug,
|
|
@@ -3034,13 +3534,14 @@ async function getPlaybookDetail(playbooksDir3, slug) {
|
|
|
3034
3534
|
body: parsed.body
|
|
3035
3535
|
};
|
|
3036
3536
|
}
|
|
3037
|
-
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;
|
|
3537
|
+
var STALE_ASSIGNMENT_MS, ATTENTION_PAGE_LIMIT, OVERVIEW_ATTENTION_LIMIT, RECENT_PROJECTS_LIMIT, RECENT_ACTIVITY_LIMIT, DEFAULT_TRANSITION_DEFINITIONS, DEFAULT_STATUS_COLORS, _cachedConfig, REFERENCED_BY_LIMIT, DEFAULT_GRAPH_COLORS;
|
|
3038
3538
|
var init_api = __esm({
|
|
3039
3539
|
"src/dashboard/api.ts"() {
|
|
3040
3540
|
"use strict";
|
|
3041
3541
|
init_lifecycle();
|
|
3042
3542
|
init_fs();
|
|
3043
3543
|
init_config2();
|
|
3544
|
+
init_assignment_resolver();
|
|
3044
3545
|
init_parser();
|
|
3045
3546
|
init_help();
|
|
3046
3547
|
STALE_ASSIGNMENT_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
@@ -3101,6 +3602,7 @@ var init_api = __esm({
|
|
|
3101
3602
|
failed: "rose"
|
|
3102
3603
|
};
|
|
3103
3604
|
_cachedConfig = null;
|
|
3605
|
+
REFERENCED_BY_LIMIT = 50;
|
|
3104
3606
|
DEFAULT_GRAPH_COLORS = {
|
|
3105
3607
|
completed: "fill:#4ea84f,stroke:#1f6b29,color:#ffffff",
|
|
3106
3608
|
in_progress: "fill:#1e6fd9,stroke:#0f3f8f,color:#ffffff",
|
|
@@ -3133,8 +3635,8 @@ __export(parser_exports, {
|
|
|
3133
3635
|
writeChecklist: () => writeChecklist
|
|
3134
3636
|
});
|
|
3135
3637
|
import { randomBytes } from "crypto";
|
|
3136
|
-
import { readFile as
|
|
3137
|
-
import { resolve as
|
|
3638
|
+
import { readFile as readFile12 } from "fs/promises";
|
|
3639
|
+
import { resolve as resolve17 } from "path";
|
|
3138
3640
|
function generateShortId() {
|
|
3139
3641
|
return randomBytes(2).toString("hex");
|
|
3140
3642
|
}
|
|
@@ -3294,10 +3796,10 @@ function serializeLogEntry(entry) {
|
|
|
3294
3796
|
return lines.join("\n");
|
|
3295
3797
|
}
|
|
3296
3798
|
function checklistPath(todosDir2, workspace) {
|
|
3297
|
-
return
|
|
3799
|
+
return resolve17(todosDir2, `${workspace}.md`);
|
|
3298
3800
|
}
|
|
3299
3801
|
function logPath(todosDir2, workspace) {
|
|
3300
|
-
return
|
|
3802
|
+
return resolve17(todosDir2, `${workspace}-log.md`);
|
|
3301
3803
|
}
|
|
3302
3804
|
function archivePath(todosDir2, workspace, interval, now = /* @__PURE__ */ new Date()) {
|
|
3303
3805
|
const year = now.getFullYear();
|
|
@@ -3321,14 +3823,14 @@ function archivePath(todosDir2, workspace, interval, now = /* @__PURE__ */ new D
|
|
|
3321
3823
|
default:
|
|
3322
3824
|
suffix = `${year}-${month}-${day}`;
|
|
3323
3825
|
}
|
|
3324
|
-
return
|
|
3826
|
+
return resolve17(todosDir2, "archive", `${workspace}-${suffix}.md`);
|
|
3325
3827
|
}
|
|
3326
3828
|
async function readChecklist(todosDir2, workspace) {
|
|
3327
3829
|
const path = checklistPath(todosDir2, workspace);
|
|
3328
3830
|
if (!await fileExists(path)) {
|
|
3329
3831
|
return { workspace, archiveInterval: "weekly", items: [] };
|
|
3330
3832
|
}
|
|
3331
|
-
const content = await
|
|
3833
|
+
const content = await readFile12(path, "utf-8");
|
|
3332
3834
|
return parseChecklist(content);
|
|
3333
3835
|
}
|
|
3334
3836
|
async function writeChecklist(todosDir2, checklist) {
|
|
@@ -3341,7 +3843,7 @@ async function readLog(todosDir2, workspace) {
|
|
|
3341
3843
|
if (!await fileExists(path)) {
|
|
3342
3844
|
return { workspace, entries: [] };
|
|
3343
3845
|
}
|
|
3344
|
-
const content = await
|
|
3846
|
+
const content = await readFile12(path, "utf-8");
|
|
3345
3847
|
return parseLog(content);
|
|
3346
3848
|
}
|
|
3347
3849
|
async function appendLogEntry2(todosDir2, workspace, entry) {
|
|
@@ -3349,7 +3851,7 @@ async function appendLogEntry2(todosDir2, workspace, entry) {
|
|
|
3349
3851
|
const path = logPath(todosDir2, workspace);
|
|
3350
3852
|
let content;
|
|
3351
3853
|
if (await fileExists(path)) {
|
|
3352
|
-
content = await
|
|
3854
|
+
content = await readFile12(path, "utf-8");
|
|
3353
3855
|
content = content.trimEnd() + "\n\n" + serializeLogEntry(entry) + "\n";
|
|
3354
3856
|
} else {
|
|
3355
3857
|
const fm = `---
|
|
@@ -4474,12 +4976,12 @@ You are working within the Syntaur protocol for multi-agent project coordination
|
|
|
4474
4976
|
_index-plans.md # Derived (read-only)
|
|
4475
4977
|
_index-decisions.md # Derived (read-only)
|
|
4476
4978
|
_status.md # Derived (read-only)
|
|
4477
|
-
claude.md # Human-authored: Claude-specific instructions (read-only)
|
|
4478
|
-
agent.md # Human-authored: universal agent instructions (read-only)
|
|
4479
4979
|
assignments/
|
|
4480
4980
|
<assignment-slug>/
|
|
4481
4981
|
assignment.md # Agent-writable: source of truth for state (includes ## Todos)
|
|
4482
4982
|
plan*.md # Agent-writable: versioned implementation plans (optional, one per ## Todos entry)
|
|
4983
|
+
progress.md # Agent-writable, append-only: timestamped progress log
|
|
4984
|
+
comments.md # CLI-mediated: threaded questions/notes/feedback (via \`syntaur comment\`)
|
|
4483
4985
|
scratchpad.md # Agent-writable: working notes
|
|
4484
4986
|
handoff.md # Agent-writable: append-only handoff log
|
|
4485
4987
|
decision-record.md # Agent-writable: append-only decision log
|
|
@@ -4489,14 +4991,24 @@ You are working within the Syntaur protocol for multi-agent project coordination
|
|
|
4489
4991
|
memories/
|
|
4490
4992
|
_index.md # Derived (read-only)
|
|
4491
4993
|
<memory-slug>.md # Shared-writable
|
|
4994
|
+
assignments/
|
|
4995
|
+
<assignment-id>/ # Standalone assignments \u2014 folder = UUID, \`project: null\`, slug display-only
|
|
4996
|
+
assignment.md
|
|
4997
|
+
plan*.md
|
|
4998
|
+
progress.md
|
|
4999
|
+
comments.md
|
|
5000
|
+
scratchpad.md
|
|
5001
|
+
handoff.md
|
|
5002
|
+
decision-record.md
|
|
4492
5003
|
\`\`\`
|
|
4493
5004
|
|
|
4494
5005
|
## Write Boundary Rules (CRITICAL)
|
|
4495
5006
|
|
|
4496
5007
|
### Files you may WRITE:
|
|
4497
5008
|
1. **Your assignment folder** -- only the assignment you are currently working on:
|
|
4498
|
-
- \`assignment.md\`, \`plan*.md\` (0 or more versioned plan files), \`scratchpad.md\`, \`handoff.md\`, \`decision-record.md\`
|
|
4499
|
-
- Path: \`~/.syntaur/projects/<project>/assignments/<your-assignment>/\`
|
|
5009
|
+
- \`assignment.md\`, \`plan*.md\` (0 or more versioned plan files), \`progress.md\`, \`scratchpad.md\`, \`handoff.md\`, \`decision-record.md\`
|
|
5010
|
+
- Path (project-nested): \`~/.syntaur/projects/<project>/assignments/<your-assignment>/\`
|
|
5011
|
+
- Path (standalone): \`~/.syntaur/assignments/<your-assignment-uuid>/\`
|
|
4500
5012
|
2. **Shared resources and memories** at the project level:
|
|
4501
5013
|
- \`~/.syntaur/projects/<project>/resources/<slug>.md\`
|
|
4502
5014
|
- \`~/.syntaur/projects/<project>/memories/<slug>.md\`
|
|
@@ -4504,11 +5016,15 @@ You are working within the Syntaur protocol for multi-agent project coordination
|
|
|
4504
5016
|
|
|
4505
5017
|
> **Note:** The \`setup-adapter\` command does not parse assignment frontmatter for workspace paths. Workspace boundaries are resolved by the agent at runtime by reading \`assignment.md\` frontmatter. If no \`workspace\` field is set, treat the current working directory as your workspace.
|
|
4506
5018
|
|
|
5019
|
+
### Files written only via CLI (never edit directly):
|
|
5020
|
+
- \`comments.md\` (any assignment) -- use \`syntaur comment <slug-or-uuid> "body" [--type question|note|feedback] [--reply-to <id>]\`
|
|
5021
|
+
- Another assignment's \`## Todos\` section -- use \`syntaur request <source> <target> "text"\` to request cross-assignment work
|
|
5022
|
+
|
|
4507
5023
|
### Files you must NEVER write:
|
|
4508
|
-
1. \`project.md
|
|
5024
|
+
1. \`project.md\` -- human-authored, read-only
|
|
4509
5025
|
2. \`manifest.md\` -- derived, rebuilt by tooling
|
|
4510
5026
|
3. Any file prefixed with \`_\` -- derived
|
|
4511
|
-
4. Other agents' assignment folders
|
|
5027
|
+
4. Other agents' assignment folders (except via the CLI-mediated channels above)
|
|
4512
5028
|
5. Any files outside your workspace boundary
|
|
4513
5029
|
|
|
4514
5030
|
## Assignment Lifecycle
|
|
@@ -4539,7 +5055,7 @@ You are working within the Syntaur protocol for multi-agent project coordination
|
|
|
4539
5055
|
|
|
4540
5056
|
## Lifecycle Commands
|
|
4541
5057
|
|
|
4542
|
-
Use the \`syntaur\` CLI for state transitions:
|
|
5058
|
+
Use the \`syntaur\` CLI for state transitions and coordination:
|
|
4543
5059
|
- \`syntaur assign <slug> --agent <name> --project <project>\` -- set assignee
|
|
4544
5060
|
- \`syntaur start <slug> --project <project>\` -- pending -> in_progress
|
|
4545
5061
|
- \`syntaur review <slug> --project <project>\` -- in_progress -> review
|
|
@@ -4547,6 +5063,9 @@ Use the \`syntaur\` CLI for state transitions:
|
|
|
4547
5063
|
- \`syntaur block <slug> --project <project> --reason <text>\` -- block an assignment
|
|
4548
5064
|
- \`syntaur unblock <slug> --project <project>\` -- unblock
|
|
4549
5065
|
- \`syntaur fail <slug> --project <project>\` -- mark as failed
|
|
5066
|
+
- \`syntaur create-assignment "Title" [--type <type>] [--project <slug> | --one-off]\` -- create project-nested or standalone assignment
|
|
5067
|
+
- \`syntaur comment <slug-or-uuid> "body" --type question|note|feedback [--reply-to <id>]\` -- append to \`comments.md\` (questions support resolve toggle via dashboard)
|
|
5068
|
+
- \`syntaur request <source> <target> "text"\` -- append a todo to another assignment's \`## Todos\` annotated \`(from: <source>)\`
|
|
4550
5069
|
|
|
4551
5070
|
## Playbooks
|
|
4552
5071
|
|
|
@@ -4560,11 +5079,13 @@ Follow the rules in each playbook. They take precedence over default conventions
|
|
|
4560
5079
|
|
|
4561
5080
|
## Conventions
|
|
4562
5081
|
|
|
4563
|
-
- Assignment frontmatter is the single source of truth for state
|
|
4564
|
-
- Slugs are lowercase, hyphen-separated
|
|
4565
|
-
- Always read \`
|
|
4566
|
-
-
|
|
4567
|
-
-
|
|
5082
|
+
- Assignment frontmatter is the single source of truth for state. \`project\` is the containing project slug (\`null\` for standalone); \`type\` is a classification validated against \`config.md\` \`types.definitions\` when present.
|
|
5083
|
+
- Slugs are lowercase, hyphen-separated. Standalone assignment folders are named by UUID; \`slug\` is display-only in that case.
|
|
5084
|
+
- Always read \`project.md\` at the project level (when project-nested) before starting work.
|
|
5085
|
+
- Append timestamped entries to \`progress.md\` (never to \`assignment.md\`).
|
|
5086
|
+
- Record questions, notes, and feedback via \`syntaur comment\`. Never edit \`comments.md\` directly.
|
|
5087
|
+
- To route work to another assignment, use \`syntaur request\`.
|
|
5088
|
+
- Commit frequently with messages referencing the assignment slug.
|
|
4568
5089
|
`;
|
|
4569
5090
|
}
|
|
4570
5091
|
function renderCursorAssignment(params) {
|
|
@@ -4584,21 +5105,25 @@ alwaysApply: true
|
|
|
4584
5105
|
## Reading Order
|
|
4585
5106
|
|
|
4586
5107
|
Before starting work, read these files in order:
|
|
4587
|
-
1. \`${params.projectDir}/
|
|
4588
|
-
2. \`${params.
|
|
4589
|
-
3. \`${params.assignmentDir}/
|
|
4590
|
-
4.
|
|
4591
|
-
5. \`${params.assignmentDir}/
|
|
5108
|
+
1. \`${params.projectDir}/project.md\` -- project overview and goals (project-nested assignments only)
|
|
5109
|
+
2. \`${params.assignmentDir}/assignment.md\` -- your assignment details, acceptance criteria, todos, current status. Frontmatter includes \`project: <slug> | null\` (null for standalone) and \`type: <classification> | null\`.
|
|
5110
|
+
3. any \`${params.assignmentDir}/plan*.md\` files linked from active todos in the \`## Todos\` section (may be 0, 1, or many)
|
|
5111
|
+
4. \`${params.assignmentDir}/progress.md\` -- reverse-chron progress log (if present)
|
|
5112
|
+
5. \`${params.assignmentDir}/comments.md\` -- threaded questions/notes/feedback (if present)
|
|
5113
|
+
6. \`${params.assignmentDir}/handoff.md\` -- previous session handoff notes
|
|
4592
5114
|
|
|
4593
5115
|
## Your Writable Files
|
|
4594
5116
|
|
|
4595
|
-
You may
|
|
5117
|
+
You may write directly to these files inside your assignment folder:
|
|
4596
5118
|
- \`${params.assignmentDir}/assignment.md\`
|
|
4597
5119
|
- \`${params.assignmentDir}/plan*.md\` (0 or more versioned plan files, e.g., \`plan.md\`, \`plan-v2.md\`)
|
|
5120
|
+
- \`${params.assignmentDir}/progress.md\` (append timestamped entries, newest first)
|
|
4598
5121
|
- \`${params.assignmentDir}/scratchpad.md\`
|
|
4599
5122
|
- \`${params.assignmentDir}/handoff.md\`
|
|
4600
5123
|
- \`${params.assignmentDir}/decision-record.md\`
|
|
4601
5124
|
|
|
5125
|
+
Do NOT edit \`${params.assignmentDir}/comments.md\` directly \u2014 use \`syntaur comment\`. Do NOT edit other assignments' files \u2014 use \`syntaur request\` for cross-assignment todos.
|
|
5126
|
+
|
|
4602
5127
|
And source code files in your workspace. Read the \`workspace\` field from your assignment's frontmatter to determine the exact boundary. If not set, the current working directory is your workspace.
|
|
4603
5128
|
`;
|
|
4604
5129
|
}
|
|
@@ -4623,10 +5148,10 @@ If the global Syntaur Codex plugin is installed, prefer these workflows instead
|
|
|
4623
5148
|
- \`syntaur-operator\` agent -- use for broad Syntaur protocol work or when a task spans multiple lifecycle steps
|
|
4624
5149
|
- \`syntaur-protocol\` -- background protocol and write-boundary rules
|
|
4625
5150
|
- \`create-project\` -- scaffold a project
|
|
4626
|
-
- \`create-assignment\` -- create a new assignment
|
|
5151
|
+
- \`create-assignment\` -- create a new assignment (use \`--type <bug|feature|chore|...>\` to classify; use \`--one-off\` to create a standalone assignment at \`~/.syntaur/assignments/<uuid>/\` with no parent project)
|
|
4627
5152
|
- \`grab-assignment\` -- claim work, create \`.syntaur/context.json\`, and register a session
|
|
4628
5153
|
- \`plan-assignment\` -- write a versioned plan file (\`plan.md\`, \`plan-v2.md\`, ...) and link it from the \`## Todos\` section of \`assignment.md\`
|
|
4629
|
-
- \`complete-assignment\` -- append the handoff, close the session, and transition state
|
|
5154
|
+
- \`complete-assignment\` -- append the handoff, append a final entry to \`progress.md\`, close the session, and transition state
|
|
4630
5155
|
- \`track-session\` -- manage tracked tmux sessions for the dashboard
|
|
4631
5156
|
|
|
4632
5157
|
If the plugin is unavailable, follow the same workflow manually with the \`syntaur\` CLI and keep the protocol files current yourself.
|
|
@@ -4634,12 +5159,12 @@ If the plugin is unavailable, follow the same workflow manually with the \`synta
|
|
|
4634
5159
|
## Reading Order
|
|
4635
5160
|
|
|
4636
5161
|
Before starting work, read these files in order:
|
|
4637
|
-
1. \`${params.projectDir}/manifest.md\` -- root navigation entry point
|
|
4638
|
-
2. \`${params.projectDir}/
|
|
4639
|
-
3. \`${params.
|
|
4640
|
-
4. \`${params.
|
|
4641
|
-
5. \`${params.assignmentDir}/
|
|
4642
|
-
6.
|
|
5162
|
+
1. \`${params.projectDir}/manifest.md\` -- root navigation entry point (project-nested assignments only)
|
|
5163
|
+
2. \`${params.projectDir}/project.md\` -- project overview and goals (project-nested assignments only)
|
|
5164
|
+
3. \`${params.assignmentDir}/assignment.md\` -- your assignment details, acceptance criteria, todos, current status. Frontmatter now includes \`project: <slug> | null\` (null for standalone) and \`type: <classification> | null\`.
|
|
5165
|
+
4. any \`${params.assignmentDir}/plan*.md\` files linked from active todos in the \`## Todos\` section (may be 0, 1, or many)
|
|
5166
|
+
5. \`${params.assignmentDir}/progress.md\` -- reverse-chron progress log (if present)
|
|
5167
|
+
6. \`${params.assignmentDir}/comments.md\` -- threaded questions/notes/feedback (if present)
|
|
4643
5168
|
7. \`${params.assignmentDir}/handoff.md\` -- previous session handoff notes
|
|
4644
5169
|
|
|
4645
5170
|
## Context File
|
|
@@ -4661,12 +5186,12 @@ Before starting work, read these files in order:
|
|
|
4661
5186
|
_index-plans.md # Derived (read-only)
|
|
4662
5187
|
_index-decisions.md # Derived (read-only)
|
|
4663
5188
|
_status.md # Derived (read-only)
|
|
4664
|
-
claude.md # Human-authored: Claude-specific instructions (read-only)
|
|
4665
|
-
agent.md # Human-authored: universal agent instructions (read-only)
|
|
4666
5189
|
assignments/
|
|
4667
5190
|
<assignment-slug>/
|
|
4668
5191
|
assignment.md # Agent-writable: source of truth for state (includes ## Todos)
|
|
4669
5192
|
plan*.md # Agent-writable: versioned implementation plans (optional, one per ## Todos entry)
|
|
5193
|
+
progress.md # Agent-writable, append-only: timestamped progress log
|
|
5194
|
+
comments.md # CLI-mediated: threaded questions/notes/feedback (via \`syntaur comment\`)
|
|
4670
5195
|
scratchpad.md # Agent-writable: working notes
|
|
4671
5196
|
handoff.md # Agent-writable: append-only handoff log
|
|
4672
5197
|
decision-record.md # Agent-writable: append-only decision log
|
|
@@ -4676,13 +5201,22 @@ Before starting work, read these files in order:
|
|
|
4676
5201
|
memories/
|
|
4677
5202
|
_index.md # Derived (read-only)
|
|
4678
5203
|
<memory-slug>.md # Shared-writable
|
|
5204
|
+
assignments/
|
|
5205
|
+
<assignment-id>/ # Standalone assignments \u2014 folder = UUID, \`project: null\`, slug display-only
|
|
5206
|
+
assignment.md
|
|
5207
|
+
plan*.md
|
|
5208
|
+
progress.md
|
|
5209
|
+
comments.md
|
|
5210
|
+
scratchpad.md
|
|
5211
|
+
handoff.md
|
|
5212
|
+
decision-record.md
|
|
4679
5213
|
\`\`\`
|
|
4680
5214
|
|
|
4681
5215
|
## Write Boundary Rules (CRITICAL)
|
|
4682
5216
|
|
|
4683
5217
|
### Files you may WRITE:
|
|
4684
5218
|
1. **Your assignment folder** -- only the assignment you are currently working on:
|
|
4685
|
-
- \`assignment.md\`, \`plan*.md\` (0 or more versioned plan files), \`scratchpad.md\`, \`handoff.md\`, \`decision-record.md\`
|
|
5219
|
+
- \`assignment.md\`, \`plan*.md\` (0 or more versioned plan files), \`progress.md\`, \`scratchpad.md\`, \`handoff.md\`, \`decision-record.md\`
|
|
4686
5220
|
- Path: \`${params.assignmentDir}/\`
|
|
4687
5221
|
2. **Shared resources and memories** at the project level:
|
|
4688
5222
|
- \`${params.projectDir}/resources/<slug>.md\`
|
|
@@ -4691,11 +5225,15 @@ Before starting work, read these files in order:
|
|
|
4691
5225
|
|
|
4692
5226
|
> **Note:** Workspace boundaries are resolved by the agent at runtime by reading \`assignment.md\` frontmatter. If no \`workspace\` field is set, treat the current working directory as your workspace.
|
|
4693
5227
|
|
|
5228
|
+
### Files written only via CLI (never edit directly):
|
|
5229
|
+
- \`comments.md\` (any assignment) -- use \`syntaur comment <slug-or-uuid> "body" [--type question|note|feedback] [--reply-to <id>]\`
|
|
5230
|
+
- Another assignment's \`## Todos\` section -- use \`syntaur request <source> <target> "text"\` to request cross-assignment work
|
|
5231
|
+
|
|
4694
5232
|
### Files you must NEVER write:
|
|
4695
|
-
1. \`project.md
|
|
5233
|
+
1. \`project.md\` -- human-authored, read-only
|
|
4696
5234
|
2. \`manifest.md\` -- derived, rebuilt by tooling
|
|
4697
5235
|
3. Any file prefixed with \`_\` -- derived
|
|
4698
|
-
4. Other agents' assignment folders
|
|
5236
|
+
4. Other agents' assignment folders (except via the CLI-mediated channels above)
|
|
4699
5237
|
5. Any files outside your workspace boundary
|
|
4700
5238
|
|
|
4701
5239
|
## Assignment Lifecycle
|
|
@@ -4726,7 +5264,7 @@ Before starting work, read these files in order:
|
|
|
4726
5264
|
|
|
4727
5265
|
## Lifecycle Commands
|
|
4728
5266
|
|
|
4729
|
-
Use the \`syntaur\` CLI for state transitions:
|
|
5267
|
+
Use the \`syntaur\` CLI for state transitions and coordination:
|
|
4730
5268
|
- \`syntaur assign ${params.assignmentSlug} --agent <name> --project ${params.projectSlug}\` -- set assignee
|
|
4731
5269
|
- \`syntaur start ${params.assignmentSlug} --project ${params.projectSlug}\` -- pending -> in_progress
|
|
4732
5270
|
- \`syntaur review ${params.assignmentSlug} --project ${params.projectSlug}\` -- in_progress -> review
|
|
@@ -4734,6 +5272,8 @@ Use the \`syntaur\` CLI for state transitions:
|
|
|
4734
5272
|
- \`syntaur block ${params.assignmentSlug} --project ${params.projectSlug} --reason <text>\` -- block
|
|
4735
5273
|
- \`syntaur unblock ${params.assignmentSlug} --project ${params.projectSlug}\` -- unblock
|
|
4736
5274
|
- \`syntaur fail ${params.assignmentSlug} --project ${params.projectSlug}\` -- mark as failed
|
|
5275
|
+
- \`syntaur comment ${params.assignmentSlug} "body" --type question|note|feedback [--reply-to <id>]\` -- append to \`comments.md\` (use for all Q&A; questions support resolve toggle)
|
|
5276
|
+
- \`syntaur request ${params.assignmentSlug} <target-slug-or-uuid> "text"\` -- append a todo to another assignment's \`## Todos\` annotated \`(from: ${params.assignmentSlug})\`
|
|
4737
5277
|
|
|
4738
5278
|
## Troubleshooting
|
|
4739
5279
|
|
|
@@ -4751,14 +5291,15 @@ Read each linked playbook and follow the rules in its body section. The \`when_t
|
|
|
4751
5291
|
|
|
4752
5292
|
## Conventions
|
|
4753
5293
|
|
|
4754
|
-
- Assignment frontmatter is the single source of truth for state
|
|
4755
|
-
- Slugs are lowercase, hyphen-separated
|
|
4756
|
-
- Always read \`
|
|
4757
|
-
- Keep \`assignment.md\`
|
|
4758
|
-
- Keep active plan file(s) current after planning changes and \`handoff.md\` current before leaving the task
|
|
4759
|
-
- When requirements shift, supersede the prior plan todo (\`- [x] ~~...~~ (superseded by plan-v<N>)\`) and write a new plan file instead of rewriting the old one
|
|
4760
|
-
-
|
|
4761
|
-
-
|
|
5294
|
+
- Assignment frontmatter is the single source of truth for state. \`project\` is the containing project slug (\`null\` for standalone); \`type\` is a classification validated against \`config.md\` \`types.definitions\` when present.
|
|
5295
|
+
- Slugs are lowercase, hyphen-separated. For standalone assignments, \`slug\` is display-only; the folder is named by the UUID.
|
|
5296
|
+
- Always read \`project.md\` at the project level (when project-nested) before starting work.
|
|
5297
|
+
- Keep \`assignment.md\` acceptance criteria and \`## Todos\` updated as work lands; append timestamped entries to \`progress.md\` (never to \`assignment.md\`).
|
|
5298
|
+
- Keep active plan file(s) current after planning changes and \`handoff.md\` current before leaving the task.
|
|
5299
|
+
- When requirements shift, supersede the prior plan todo (\`- [x] ~~...~~ (superseded by plan-v<N>)\`) and write a new plan file instead of rewriting the old one.
|
|
5300
|
+
- Record questions, notes, and feedback via \`syntaur comment\`. Never edit \`comments.md\` directly. Resolve questions via the dashboard UI (toggle on the question entry).
|
|
5301
|
+
- To route work to another assignment, use \`syntaur request\`.
|
|
5302
|
+
- Commit frequently with messages referencing the assignment slug.
|
|
4762
5303
|
`;
|
|
4763
5304
|
}
|
|
4764
5305
|
|
|
@@ -4766,8 +5307,12 @@ Read each linked playbook and follow the rules in its body section. The \`when_t
|
|
|
4766
5307
|
function renderOpenCodeConfig(params) {
|
|
4767
5308
|
const config = {
|
|
4768
5309
|
instructions: [
|
|
4769
|
-
`Read AGENTS.md in this directory for Syntaur protocol instructions.`,
|
|
4770
|
-
`
|
|
5310
|
+
`Read AGENTS.md in this directory for Syntaur protocol (v2.0) instructions.`,
|
|
5311
|
+
`Read ${params.projectDir}/project.md for project overview (project-nested assignments only).`,
|
|
5312
|
+
`Append timestamped progress entries to the assignment's progress.md (not to assignment.md).`,
|
|
5313
|
+
`Use 'syntaur comment <slug-or-uuid> "body" --type question|note|feedback' to append to comments.md \u2014 never edit it directly.`,
|
|
5314
|
+
`Use 'syntaur request <source> <target> "text"' to append a todo to another assignment's ## Todos.`,
|
|
5315
|
+
`Assignment folders are project-nested at ~/.syntaur/projects/<slug>/assignments/<aslug>/ or standalone at ~/.syntaur/assignments/<uuid>/ (project: null, slug display-only).`
|
|
4771
5316
|
]
|
|
4772
5317
|
};
|
|
4773
5318
|
return JSON.stringify(config, null, 2) + "\n";
|
|
@@ -5044,23 +5589,334 @@ Use --slug to specify a different slug.`
|
|
|
5044
5589
|
init_config2();
|
|
5045
5590
|
import { spawn } from "child_process";
|
|
5046
5591
|
import { createServer as createNetServer } from "net";
|
|
5047
|
-
import { resolve as
|
|
5592
|
+
import { resolve as resolve20, dirname as dirname4 } from "path";
|
|
5048
5593
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
5049
5594
|
|
|
5050
5595
|
// src/dashboard/server.ts
|
|
5051
5596
|
init_paths();
|
|
5052
5597
|
init_api();
|
|
5598
|
+
init_assignment_resolver();
|
|
5053
5599
|
import express from "express";
|
|
5054
5600
|
import { createServer } from "http";
|
|
5055
|
-
import { resolve as
|
|
5601
|
+
import { resolve as resolve19 } from "path";
|
|
5056
5602
|
import { writeFile as writeFile4, unlink as unlink4 } from "fs/promises";
|
|
5057
5603
|
import { WebSocketServer, WebSocket } from "ws";
|
|
5058
5604
|
|
|
5059
|
-
// src/dashboard/
|
|
5060
|
-
|
|
5061
|
-
import {
|
|
5605
|
+
// src/dashboard/agent-sessions.ts
|
|
5606
|
+
init_fs();
|
|
5607
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
5608
|
+
import { resolve as resolve13 } from "path";
|
|
5609
|
+
|
|
5610
|
+
// src/dashboard/session-db.ts
|
|
5611
|
+
init_paths();
|
|
5612
|
+
init_fs();
|
|
5613
|
+
import Database from "better-sqlite3";
|
|
5614
|
+
import { resolve as resolve12 } from "path";
|
|
5615
|
+
import { readdir as readdir7 } from "fs/promises";
|
|
5616
|
+
var db = null;
|
|
5617
|
+
var SCHEMA_VERSION = "3";
|
|
5618
|
+
var SCHEMA_SQL = `
|
|
5619
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
5620
|
+
session_id TEXT PRIMARY KEY,
|
|
5621
|
+
project_slug TEXT,
|
|
5622
|
+
assignment_slug TEXT,
|
|
5623
|
+
agent TEXT NOT NULL,
|
|
5624
|
+
started TEXT NOT NULL,
|
|
5625
|
+
ended TEXT,
|
|
5626
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
5627
|
+
path TEXT,
|
|
5628
|
+
description TEXT,
|
|
5629
|
+
transcript_path TEXT,
|
|
5630
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
5631
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
5632
|
+
);
|
|
5633
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
|
|
5634
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
|
|
5635
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
5636
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
5637
|
+
`;
|
|
5638
|
+
function initSessionDb(dbPath) {
|
|
5639
|
+
if (db) return db;
|
|
5640
|
+
const finalPath = dbPath ?? resolve12(syntaurRoot(), "syntaur.db");
|
|
5641
|
+
db = new Database(finalPath);
|
|
5642
|
+
db.pragma("journal_mode = WAL");
|
|
5643
|
+
db.exec(SCHEMA_SQL);
|
|
5644
|
+
db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
|
|
5645
|
+
"schema_version",
|
|
5646
|
+
SCHEMA_VERSION
|
|
5647
|
+
);
|
|
5648
|
+
const currentVersion = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
5649
|
+
if (currentVersion?.value === "1") {
|
|
5650
|
+
db.exec(`
|
|
5651
|
+
CREATE TABLE sessions_v2 (
|
|
5652
|
+
session_id TEXT PRIMARY KEY,
|
|
5653
|
+
project_slug TEXT,
|
|
5654
|
+
assignment_slug TEXT,
|
|
5655
|
+
agent TEXT NOT NULL,
|
|
5656
|
+
started TEXT NOT NULL,
|
|
5657
|
+
ended TEXT,
|
|
5658
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
5659
|
+
path TEXT,
|
|
5660
|
+
description TEXT,
|
|
5661
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
5662
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
5663
|
+
);
|
|
5664
|
+
INSERT INTO sessions_v2 SELECT session_id, project_slug, assignment_slug, agent, started, ended, status, path, NULL, created_at, updated_at FROM sessions;
|
|
5665
|
+
DROP TABLE sessions;
|
|
5666
|
+
ALTER TABLE sessions_v2 RENAME TO sessions;
|
|
5667
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
|
|
5668
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
|
|
5669
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
5670
|
+
UPDATE meta SET value = '2' WHERE key = 'schema_version';
|
|
5671
|
+
`);
|
|
5672
|
+
}
|
|
5673
|
+
const versionAfterV1 = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
5674
|
+
if (versionAfterV1?.value === "2") {
|
|
5675
|
+
db.exec(`
|
|
5676
|
+
CREATE TABLE sessions_v3 (
|
|
5677
|
+
session_id TEXT PRIMARY KEY,
|
|
5678
|
+
project_slug TEXT,
|
|
5679
|
+
assignment_slug TEXT,
|
|
5680
|
+
agent TEXT NOT NULL,
|
|
5681
|
+
started TEXT NOT NULL,
|
|
5682
|
+
ended TEXT,
|
|
5683
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
5684
|
+
path TEXT,
|
|
5685
|
+
description TEXT,
|
|
5686
|
+
transcript_path TEXT,
|
|
5687
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
5688
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
5689
|
+
);
|
|
5690
|
+
INSERT INTO sessions_v3 SELECT session_id, project_slug, assignment_slug, agent, started, ended, status, path, description, NULL, created_at, updated_at FROM sessions;
|
|
5691
|
+
DROP TABLE sessions;
|
|
5692
|
+
ALTER TABLE sessions_v3 RENAME TO sessions;
|
|
5693
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
|
|
5694
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
|
|
5695
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
5696
|
+
UPDATE meta SET value = '3' WHERE key = 'schema_version';
|
|
5697
|
+
`);
|
|
5698
|
+
}
|
|
5699
|
+
return db;
|
|
5700
|
+
}
|
|
5701
|
+
function getSessionDb() {
|
|
5702
|
+
if (!db) {
|
|
5703
|
+
throw new Error(
|
|
5704
|
+
"Session database not initialized. Call initSessionDb() first."
|
|
5705
|
+
);
|
|
5706
|
+
}
|
|
5707
|
+
return db;
|
|
5708
|
+
}
|
|
5709
|
+
function closeSessionDb() {
|
|
5710
|
+
if (db) {
|
|
5711
|
+
db.close();
|
|
5712
|
+
db = null;
|
|
5713
|
+
}
|
|
5714
|
+
}
|
|
5715
|
+
async function migrateFromMarkdown(projectsDir2) {
|
|
5716
|
+
const database = getSessionDb();
|
|
5717
|
+
const count = database.prepare("SELECT COUNT(*) as count FROM sessions").get();
|
|
5718
|
+
if (count.count > 0) return 0;
|
|
5719
|
+
if (!await fileExists(projectsDir2)) return 0;
|
|
5720
|
+
const entries = await readdir7(projectsDir2, { withFileTypes: true });
|
|
5721
|
+
const allSessions = [];
|
|
5722
|
+
for (const entry of entries) {
|
|
5723
|
+
if (!entry.isDirectory()) continue;
|
|
5724
|
+
const projectDir = resolve12(projectsDir2, entry.name);
|
|
5725
|
+
const indexPath = resolve12(projectDir, "_index-sessions.md");
|
|
5726
|
+
if (!await fileExists(indexPath)) continue;
|
|
5727
|
+
const sessions = await parseMarkdownSessionsIndex(indexPath, entry.name);
|
|
5728
|
+
allSessions.push(...sessions);
|
|
5729
|
+
}
|
|
5730
|
+
if (allSessions.length === 0) return 0;
|
|
5731
|
+
const insert = database.prepare(`
|
|
5732
|
+
INSERT OR IGNORE INTO sessions (session_id, project_slug, assignment_slug, agent, started, status, path)
|
|
5733
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
5734
|
+
`);
|
|
5735
|
+
const insertAll = database.transaction((sessions) => {
|
|
5736
|
+
for (const s of sessions) {
|
|
5737
|
+
insert.run(s.sessionId, s.projectSlug, s.assignmentSlug, s.agent, s.started, s.status, s.path);
|
|
5738
|
+
}
|
|
5739
|
+
});
|
|
5740
|
+
insertAll(allSessions);
|
|
5741
|
+
console.log(`Migrated ${allSessions.length} sessions from markdown to SQLite.`);
|
|
5742
|
+
return allSessions.length;
|
|
5743
|
+
}
|
|
5744
|
+
async function parseMarkdownSessionsIndex(filePath, projectSlug) {
|
|
5745
|
+
const { readFile: readFile25 } = await import("fs/promises");
|
|
5746
|
+
const raw = await readFile25(filePath, "utf-8");
|
|
5747
|
+
const sessions = [];
|
|
5748
|
+
const lines = raw.split("\n");
|
|
5749
|
+
let inTable = false;
|
|
5750
|
+
let headerSeen = false;
|
|
5751
|
+
for (const line of lines) {
|
|
5752
|
+
const trimmed = line.trim();
|
|
5753
|
+
if (!trimmed) continue;
|
|
5754
|
+
if (trimmed.startsWith("| Assignment") || trimmed.startsWith("|Assignment")) {
|
|
5755
|
+
inTable = true;
|
|
5756
|
+
headerSeen = false;
|
|
5757
|
+
continue;
|
|
5758
|
+
}
|
|
5759
|
+
if (inTable && !headerSeen && trimmed.match(/^\|[-\s|]+\|$/)) {
|
|
5760
|
+
headerSeen = true;
|
|
5761
|
+
continue;
|
|
5762
|
+
}
|
|
5763
|
+
if (inTable && headerSeen && trimmed.startsWith("|")) {
|
|
5764
|
+
const cells = trimmed.split("|").slice(1, -1).map((c2) => c2.trim());
|
|
5765
|
+
if (cells.length >= 6) {
|
|
5766
|
+
sessions.push({
|
|
5767
|
+
assignmentSlug: cells[0],
|
|
5768
|
+
agent: cells[1],
|
|
5769
|
+
sessionId: cells[2],
|
|
5770
|
+
started: cells[3],
|
|
5771
|
+
status: cells[4] || "active",
|
|
5772
|
+
path: cells[5],
|
|
5773
|
+
projectSlug
|
|
5774
|
+
});
|
|
5775
|
+
}
|
|
5776
|
+
}
|
|
5777
|
+
}
|
|
5778
|
+
return sessions;
|
|
5779
|
+
}
|
|
5780
|
+
|
|
5781
|
+
// src/dashboard/agent-sessions.ts
|
|
5782
|
+
function rowToSession(row) {
|
|
5783
|
+
return {
|
|
5784
|
+
sessionId: row.session_id,
|
|
5785
|
+
projectSlug: row.project_slug ?? null,
|
|
5786
|
+
assignmentSlug: row.assignment_slug ?? null,
|
|
5787
|
+
agent: row.agent,
|
|
5788
|
+
started: row.started,
|
|
5789
|
+
ended: row.ended ?? null,
|
|
5790
|
+
status: row.status,
|
|
5791
|
+
path: row.path ?? "",
|
|
5792
|
+
description: row.description ?? null,
|
|
5793
|
+
transcriptPath: row.transcript_path ?? null
|
|
5794
|
+
};
|
|
5795
|
+
}
|
|
5796
|
+
async function appendSession(_projectDir, session) {
|
|
5797
|
+
const db2 = getSessionDb();
|
|
5798
|
+
db2.prepare(`
|
|
5799
|
+
INSERT INTO sessions (session_id, project_slug, assignment_slug, agent, started, status, path, description, transcript_path)
|
|
5800
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
5801
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
5802
|
+
project_slug = COALESCE(excluded.project_slug, project_slug),
|
|
5803
|
+
assignment_slug = COALESCE(excluded.assignment_slug, assignment_slug),
|
|
5804
|
+
agent = excluded.agent,
|
|
5805
|
+
status = CASE WHEN status IN ('completed','stopped') THEN status ELSE excluded.status END,
|
|
5806
|
+
path = COALESCE(excluded.path, path),
|
|
5807
|
+
description = COALESCE(excluded.description, description),
|
|
5808
|
+
transcript_path = COALESCE(excluded.transcript_path, transcript_path),
|
|
5809
|
+
updated_at = datetime('now')
|
|
5810
|
+
`).run(
|
|
5811
|
+
session.sessionId,
|
|
5812
|
+
session.projectSlug ?? null,
|
|
5813
|
+
session.assignmentSlug ?? null,
|
|
5814
|
+
session.agent,
|
|
5815
|
+
session.started,
|
|
5816
|
+
session.status,
|
|
5817
|
+
session.path,
|
|
5818
|
+
session.description ?? null,
|
|
5819
|
+
session.transcriptPath ?? null
|
|
5820
|
+
);
|
|
5821
|
+
}
|
|
5822
|
+
async function updateSessionStatus(_projectDir, sessionId, status) {
|
|
5823
|
+
const db2 = getSessionDb();
|
|
5824
|
+
const isTerminal = status === "completed" || status === "stopped";
|
|
5825
|
+
const result = isTerminal ? db2.prepare(
|
|
5826
|
+
"UPDATE sessions SET status = ?, ended = datetime('now'), updated_at = datetime('now') WHERE session_id = ?"
|
|
5827
|
+
).run(status, sessionId) : db2.prepare(
|
|
5828
|
+
"UPDATE sessions SET status = ?, updated_at = datetime('now') WHERE session_id = ?"
|
|
5829
|
+
).run(status, sessionId);
|
|
5830
|
+
return result.changes > 0;
|
|
5831
|
+
}
|
|
5832
|
+
async function listAllSessions(_projectsDir) {
|
|
5833
|
+
const db2 = getSessionDb();
|
|
5834
|
+
const rows = db2.prepare("SELECT * FROM sessions ORDER BY started DESC").all();
|
|
5835
|
+
return rows.map(rowToSession);
|
|
5836
|
+
}
|
|
5837
|
+
async function listProjectSessions(_projectsDir, projectSlug, assignmentSlug) {
|
|
5838
|
+
const db2 = getSessionDb();
|
|
5839
|
+
if (assignmentSlug) {
|
|
5840
|
+
const rows2 = db2.prepare(
|
|
5841
|
+
"SELECT * FROM sessions WHERE project_slug = ? AND assignment_slug = ? ORDER BY started DESC"
|
|
5842
|
+
).all(projectSlug, assignmentSlug);
|
|
5843
|
+
return rows2.map(rowToSession);
|
|
5844
|
+
}
|
|
5845
|
+
const rows = db2.prepare("SELECT * FROM sessions WHERE project_slug = ? ORDER BY started DESC").all(projectSlug);
|
|
5846
|
+
return rows.map(rowToSession);
|
|
5847
|
+
}
|
|
5848
|
+
async function deleteSessions(sessionIds) {
|
|
5849
|
+
if (sessionIds.length === 0) return 0;
|
|
5850
|
+
const db2 = getSessionDb();
|
|
5851
|
+
const placeholders = sessionIds.map(() => "?").join(", ");
|
|
5852
|
+
const result = db2.prepare(`DELETE FROM sessions WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
|
5853
|
+
return result.changes;
|
|
5854
|
+
}
|
|
5855
|
+
var DONE_ASSIGNMENT_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "review"]);
|
|
5856
|
+
async function readAssignmentStatusFromPath(assignmentMdPath) {
|
|
5857
|
+
if (!await fileExists(assignmentMdPath)) return null;
|
|
5858
|
+
const raw = await readFile9(assignmentMdPath, "utf-8");
|
|
5859
|
+
const match = raw.match(/^status:\s*(.+)$/m);
|
|
5860
|
+
return match ? match[1].trim() : null;
|
|
5861
|
+
}
|
|
5862
|
+
async function readAssignmentStatus(projectDir, assignmentSlug) {
|
|
5863
|
+
return readAssignmentStatusFromPath(
|
|
5864
|
+
resolve13(projectDir, "assignments", assignmentSlug, "assignment.md")
|
|
5865
|
+
);
|
|
5866
|
+
}
|
|
5867
|
+
async function reconcileActiveSessions(projectsDir2, assignmentsDir2) {
|
|
5868
|
+
const db2 = getSessionDb();
|
|
5869
|
+
const activeSessions = db2.prepare("SELECT * FROM sessions WHERE status = 'active' AND assignment_slug IS NOT NULL").all();
|
|
5870
|
+
if (activeSessions.length === 0) return 0;
|
|
5871
|
+
const assignmentStatuses = /* @__PURE__ */ new Map();
|
|
5872
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5873
|
+
for (const session of activeSessions) {
|
|
5874
|
+
const aslug = session.assignment_slug;
|
|
5875
|
+
if (!aslug) continue;
|
|
5876
|
+
const projectKey = session.project_slug ?? "__standalone__";
|
|
5877
|
+
const key = `${projectKey}/${aslug}`;
|
|
5878
|
+
if (seen.has(key)) continue;
|
|
5879
|
+
seen.add(key);
|
|
5880
|
+
if (session.project_slug) {
|
|
5881
|
+
const status = await readAssignmentStatus(
|
|
5882
|
+
resolve13(projectsDir2, session.project_slug),
|
|
5883
|
+
aslug
|
|
5884
|
+
);
|
|
5885
|
+
if (status) assignmentStatuses.set(key, status);
|
|
5886
|
+
} else if (assignmentsDir2) {
|
|
5887
|
+
const status = await readAssignmentStatusFromPath(
|
|
5888
|
+
resolve13(assignmentsDir2, aslug, "assignment.md")
|
|
5889
|
+
);
|
|
5890
|
+
if (status) assignmentStatuses.set(key, status);
|
|
5891
|
+
}
|
|
5892
|
+
}
|
|
5893
|
+
let totalUpdated = 0;
|
|
5894
|
+
for (const session of activeSessions) {
|
|
5895
|
+
const projectKey = session.project_slug ?? "__standalone__";
|
|
5896
|
+
const key = `${projectKey}/${session.assignment_slug}`;
|
|
5897
|
+
const assignmentStatus = assignmentStatuses.get(key);
|
|
5898
|
+
if (!assignmentStatus || !DONE_ASSIGNMENT_STATUSES.has(assignmentStatus)) continue;
|
|
5899
|
+
const newStatus = assignmentStatus === "failed" ? "stopped" : "completed";
|
|
5900
|
+
await updateSessionStatus("", session.session_id, newStatus);
|
|
5901
|
+
totalUpdated++;
|
|
5902
|
+
}
|
|
5903
|
+
return totalUpdated;
|
|
5904
|
+
}
|
|
5905
|
+
async function listSessionsByAssignment(projectSlug, assignmentSlug) {
|
|
5906
|
+
const db2 = getSessionDb();
|
|
5907
|
+
const rows = projectSlug === null ? db2.prepare(
|
|
5908
|
+
"SELECT * FROM sessions WHERE assignment_slug = ? AND project_slug IS NULL ORDER BY started DESC"
|
|
5909
|
+
).all(assignmentSlug) : db2.prepare(
|
|
5910
|
+
"SELECT * FROM sessions WHERE project_slug = ? AND assignment_slug = ? ORDER BY started DESC"
|
|
5911
|
+
).all(projectSlug, assignmentSlug);
|
|
5912
|
+
return rows.map(rowToSession);
|
|
5913
|
+
}
|
|
5914
|
+
|
|
5915
|
+
// src/dashboard/watcher.ts
|
|
5916
|
+
import { watch } from "chokidar";
|
|
5917
|
+
import { relative, sep } from "path";
|
|
5062
5918
|
function createWatcher(options) {
|
|
5063
|
-
const { projectsDir: projectsDir2, serversDir: serversDir2, playbooksDir: playbooksDir3, todosDir: todosDir2, onMessage, debounceMs = 300 } = options;
|
|
5919
|
+
const { projectsDir: projectsDir2, assignmentsDir: assignmentsDir2, serversDir: serversDir2, playbooksDir: playbooksDir3, todosDir: todosDir2, onMessage, debounceMs = 300 } = options;
|
|
5064
5920
|
const pendingEvents = /* @__PURE__ */ new Map();
|
|
5065
5921
|
const projectsWatcher = watch(projectsDir2, {
|
|
5066
5922
|
ignoreInitial: true,
|
|
@@ -5098,6 +5954,42 @@ function createWatcher(options) {
|
|
|
5098
5954
|
projectsWatcher.on("change", handleProjectChange);
|
|
5099
5955
|
projectsWatcher.on("add", handleProjectChange);
|
|
5100
5956
|
projectsWatcher.on("unlink", handleProjectChange);
|
|
5957
|
+
let standaloneWatcher = null;
|
|
5958
|
+
if (assignmentsDir2) {
|
|
5959
|
+
let handleStandaloneChange2 = function(filePath) {
|
|
5960
|
+
const rel = relative(assignmentsDir2, filePath);
|
|
5961
|
+
const parts = rel.split(sep);
|
|
5962
|
+
if (parts.length === 0) return;
|
|
5963
|
+
const assignmentId = parts[0];
|
|
5964
|
+
if (!assignmentId) return;
|
|
5965
|
+
const debounceKey = `__standalone__/${assignmentId}`;
|
|
5966
|
+
const existing = pendingEvents.get(debounceKey);
|
|
5967
|
+
if (existing) clearTimeout(existing);
|
|
5968
|
+
pendingEvents.set(
|
|
5969
|
+
debounceKey,
|
|
5970
|
+
setTimeout(() => {
|
|
5971
|
+
pendingEvents.delete(debounceKey);
|
|
5972
|
+
const message = {
|
|
5973
|
+
type: "assignment-updated",
|
|
5974
|
+
projectSlug: null,
|
|
5975
|
+
assignmentSlug: assignmentId,
|
|
5976
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
5977
|
+
};
|
|
5978
|
+
onMessage(message);
|
|
5979
|
+
}, debounceMs)
|
|
5980
|
+
);
|
|
5981
|
+
};
|
|
5982
|
+
var handleStandaloneChange = handleStandaloneChange2;
|
|
5983
|
+
standaloneWatcher = watch(assignmentsDir2, {
|
|
5984
|
+
ignoreInitial: true,
|
|
5985
|
+
persistent: true,
|
|
5986
|
+
depth: 5,
|
|
5987
|
+
ignored: /(^|[\/\\])\../
|
|
5988
|
+
});
|
|
5989
|
+
standaloneWatcher.on("change", handleStandaloneChange2);
|
|
5990
|
+
standaloneWatcher.on("add", handleStandaloneChange2);
|
|
5991
|
+
standaloneWatcher.on("unlink", handleStandaloneChange2);
|
|
5992
|
+
}
|
|
5101
5993
|
let serversWatcher = null;
|
|
5102
5994
|
if (serversDir2) {
|
|
5103
5995
|
let handleServerChange2 = function() {
|
|
@@ -5192,6 +6084,7 @@ function createWatcher(options) {
|
|
|
5192
6084
|
});
|
|
5193
6085
|
pendingEvents.clear();
|
|
5194
6086
|
await projectsWatcher.close();
|
|
6087
|
+
if (standaloneWatcher) await standaloneWatcher.close();
|
|
5195
6088
|
if (serversWatcher) await serversWatcher.close();
|
|
5196
6089
|
if (playbooksWatcher) await playbooksWatcher.close();
|
|
5197
6090
|
if (todosWatcher) await todosWatcher.close();
|
|
@@ -5206,8 +6099,8 @@ init_config2();
|
|
|
5206
6099
|
// src/dashboard/api-write.ts
|
|
5207
6100
|
init_lifecycle();
|
|
5208
6101
|
import { Router } from "express";
|
|
5209
|
-
import { resolve as
|
|
5210
|
-
import { rm, readFile as
|
|
6102
|
+
import { resolve as resolve14 } from "path";
|
|
6103
|
+
import { rm, readFile as readFile10 } from "fs/promises";
|
|
5211
6104
|
init_timestamp();
|
|
5212
6105
|
init_fs();
|
|
5213
6106
|
init_parser();
|
|
@@ -5259,6 +6152,9 @@ function toggleAcceptanceCriterion(content, index, checked) {
|
|
|
5259
6152
|
|
|
5260
6153
|
// src/dashboard/api-write.ts
|
|
5261
6154
|
init_api();
|
|
6155
|
+
init_assignment_resolver();
|
|
6156
|
+
init_lifecycle();
|
|
6157
|
+
init_parser();
|
|
5262
6158
|
function extractFrontmatter3(content) {
|
|
5263
6159
|
const trimmed = content.trimStart();
|
|
5264
6160
|
if (!trimmed.startsWith("---\n") && !trimmed.startsWith("---\r\n")) {
|
|
@@ -5358,9 +6254,9 @@ async function readCurrentDocument(filePath) {
|
|
|
5358
6254
|
if (!await fileExists(filePath)) {
|
|
5359
6255
|
return null;
|
|
5360
6256
|
}
|
|
5361
|
-
return
|
|
6257
|
+
return readFile10(filePath, "utf-8");
|
|
5362
6258
|
}
|
|
5363
|
-
function createWriteRouter(projectsDir2) {
|
|
6259
|
+
function createWriteRouter(projectsDir2, assignmentsDir2) {
|
|
5364
6260
|
const router = Router();
|
|
5365
6261
|
router.get("/api/templates/project", (_req, res) => {
|
|
5366
6262
|
const content = renderProject({
|
|
@@ -5488,26 +6384,26 @@ function createWriteRouter(projectsDir2) {
|
|
|
5488
6384
|
res.status(400).json({ error: `Invalid slug "${slug}". Must be lowercase and hyphen-separated.` });
|
|
5489
6385
|
return;
|
|
5490
6386
|
}
|
|
5491
|
-
const projectDir =
|
|
6387
|
+
const projectDir = resolve14(projectsDir2, slug);
|
|
5492
6388
|
if (await fileExists(projectDir)) {
|
|
5493
6389
|
res.status(409).json({ error: `Project "${slug}" already exists` });
|
|
5494
6390
|
return;
|
|
5495
6391
|
}
|
|
5496
6392
|
const title = fields.title;
|
|
5497
6393
|
const timestamp = fields.created || nowTimestamp();
|
|
5498
|
-
await ensureDir(
|
|
5499
|
-
await ensureDir(
|
|
5500
|
-
await ensureDir(
|
|
5501
|
-
await writeFileForce(
|
|
6394
|
+
await ensureDir(resolve14(projectDir, "assignments"));
|
|
6395
|
+
await ensureDir(resolve14(projectDir, "resources"));
|
|
6396
|
+
await ensureDir(resolve14(projectDir, "memories"));
|
|
6397
|
+
await writeFileForce(resolve14(projectDir, "project.md"), content);
|
|
5502
6398
|
try {
|
|
5503
6399
|
const companions = [
|
|
5504
|
-
[
|
|
5505
|
-
[
|
|
5506
|
-
[
|
|
5507
|
-
[
|
|
5508
|
-
[
|
|
5509
|
-
[
|
|
5510
|
-
[
|
|
6400
|
+
[resolve14(projectDir, "manifest.md"), renderManifest({ slug, timestamp })],
|
|
6401
|
+
[resolve14(projectDir, "_index-assignments.md"), renderIndexAssignments({ slug, title, timestamp })],
|
|
6402
|
+
[resolve14(projectDir, "_index-plans.md"), renderIndexPlans({ slug, title, timestamp })],
|
|
6403
|
+
[resolve14(projectDir, "_index-decisions.md"), renderIndexDecisions({ slug, title, timestamp })],
|
|
6404
|
+
[resolve14(projectDir, "_status.md"), renderStatus({ slug, title, timestamp })],
|
|
6405
|
+
[resolve14(projectDir, "resources", "_index.md"), renderResourcesIndex({ slug, title, timestamp })],
|
|
6406
|
+
[resolve14(projectDir, "memories", "_index.md"), renderMemoriesIndex({ slug, title, timestamp })]
|
|
5511
6407
|
];
|
|
5512
6408
|
for (const [filePath, fileContent] of companions) {
|
|
5513
6409
|
await writeFileForce(filePath, fileContent);
|
|
@@ -5528,8 +6424,8 @@ function createWriteRouter(projectsDir2) {
|
|
|
5528
6424
|
router.post("/api/projects/:slug/assignments", async (req, res) => {
|
|
5529
6425
|
try {
|
|
5530
6426
|
const projectSlug = getParam(req.params.slug);
|
|
5531
|
-
const projectDir =
|
|
5532
|
-
const projectMdPath =
|
|
6427
|
+
const projectDir = resolve14(projectsDir2, projectSlug);
|
|
6428
|
+
const projectMdPath = resolve14(projectDir, "project.md");
|
|
5533
6429
|
if (!await fileExists(projectMdPath)) {
|
|
5534
6430
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
5535
6431
|
return;
|
|
@@ -5559,7 +6455,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5559
6455
|
res.status(400).json({ error: `Invalid priority "${priority}". Must be low, medium, high, or critical.` });
|
|
5560
6456
|
return;
|
|
5561
6457
|
}
|
|
5562
|
-
const assignmentDir =
|
|
6458
|
+
const assignmentDir = resolve14(projectDir, "assignments", assignmentSlug);
|
|
5563
6459
|
if (await fileExists(assignmentDir)) {
|
|
5564
6460
|
res.status(409).json({
|
|
5565
6461
|
error: `Assignment "${assignmentSlug}" already exists in project "${projectSlug}"`
|
|
@@ -5568,12 +6464,12 @@ function createWriteRouter(projectsDir2) {
|
|
|
5568
6464
|
}
|
|
5569
6465
|
const timestamp = fields.created || nowTimestamp();
|
|
5570
6466
|
await ensureDir(assignmentDir);
|
|
5571
|
-
await writeFileForce(
|
|
6467
|
+
await writeFileForce(resolve14(assignmentDir, "assignment.md"), content);
|
|
5572
6468
|
try {
|
|
5573
6469
|
const companions = [
|
|
5574
|
-
[
|
|
5575
|
-
[
|
|
5576
|
-
[
|
|
6470
|
+
[resolve14(assignmentDir, "scratchpad.md"), renderScratchpad({ assignmentSlug, timestamp })],
|
|
6471
|
+
[resolve14(assignmentDir, "handoff.md"), renderHandoff({ assignmentSlug, timestamp })],
|
|
6472
|
+
[resolve14(assignmentDir, "decision-record.md"), renderDecisionRecord({ assignmentSlug, timestamp })]
|
|
5577
6473
|
];
|
|
5578
6474
|
for (const [filePath, fileContent] of companions) {
|
|
5579
6475
|
await writeFileForce(filePath, fileContent);
|
|
@@ -5594,7 +6490,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5594
6490
|
router.patch("/api/projects/:slug", async (req, res) => {
|
|
5595
6491
|
try {
|
|
5596
6492
|
const projectSlug = getParam(req.params.slug);
|
|
5597
|
-
const projectPath =
|
|
6493
|
+
const projectPath = resolve14(projectsDir2, projectSlug, "project.md");
|
|
5598
6494
|
const currentContent = await readCurrentDocument(projectPath);
|
|
5599
6495
|
if (!currentContent) {
|
|
5600
6496
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
@@ -5627,7 +6523,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5627
6523
|
try {
|
|
5628
6524
|
const projectSlug = getParam(req.params.slug);
|
|
5629
6525
|
const assignmentSlug = getParam(req.params.aslug);
|
|
5630
|
-
const assignmentPath =
|
|
6526
|
+
const assignmentPath = resolve14(
|
|
5631
6527
|
projectsDir2,
|
|
5632
6528
|
projectSlug,
|
|
5633
6529
|
"assignments",
|
|
@@ -5670,7 +6566,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5670
6566
|
try {
|
|
5671
6567
|
const projectSlug = getParam(req.params.slug);
|
|
5672
6568
|
const assignmentSlug = getParam(req.params.aslug);
|
|
5673
|
-
const assignmentPath =
|
|
6569
|
+
const assignmentPath = resolve14(
|
|
5674
6570
|
projectsDir2,
|
|
5675
6571
|
projectSlug,
|
|
5676
6572
|
"assignments",
|
|
@@ -5706,7 +6602,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5706
6602
|
try {
|
|
5707
6603
|
const projectSlug = getParam(req.params.slug);
|
|
5708
6604
|
const assignmentSlug = getParam(req.params.aslug);
|
|
5709
|
-
const planPath =
|
|
6605
|
+
const planPath = resolve14(
|
|
5710
6606
|
projectsDir2,
|
|
5711
6607
|
projectSlug,
|
|
5712
6608
|
"assignments",
|
|
@@ -5744,7 +6640,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5744
6640
|
try {
|
|
5745
6641
|
const projectSlug = getParam(req.params.slug);
|
|
5746
6642
|
const assignmentSlug = getParam(req.params.aslug);
|
|
5747
|
-
const scratchpadPath =
|
|
6643
|
+
const scratchpadPath = resolve14(
|
|
5748
6644
|
projectsDir2,
|
|
5749
6645
|
projectSlug,
|
|
5750
6646
|
"assignments",
|
|
@@ -5782,7 +6678,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5782
6678
|
try {
|
|
5783
6679
|
const projectSlug = getParam(req.params.slug);
|
|
5784
6680
|
const assignmentSlug = getParam(req.params.aslug);
|
|
5785
|
-
const handoffPath =
|
|
6681
|
+
const handoffPath = resolve14(
|
|
5786
6682
|
projectsDir2,
|
|
5787
6683
|
projectSlug,
|
|
5788
6684
|
"assignments",
|
|
@@ -5820,7 +6716,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5820
6716
|
try {
|
|
5821
6717
|
const projectSlug = getParam(req.params.slug);
|
|
5822
6718
|
const assignmentSlug = getParam(req.params.aslug);
|
|
5823
|
-
const decisionPath =
|
|
6719
|
+
const decisionPath = resolve14(
|
|
5824
6720
|
projectsDir2,
|
|
5825
6721
|
projectSlug,
|
|
5826
6722
|
"assignments",
|
|
@@ -5854,10 +6750,121 @@ function createWriteRouter(projectsDir2) {
|
|
|
5854
6750
|
res.status(500).json({ error: `Failed to append decision entry: ${error.message}` });
|
|
5855
6751
|
}
|
|
5856
6752
|
});
|
|
6753
|
+
router.post("/api/projects/:slug/assignments/:aslug/comments", async (req, res) => {
|
|
6754
|
+
try {
|
|
6755
|
+
const projectSlug = getParam(req.params.slug);
|
|
6756
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
6757
|
+
const commentsPath = resolve14(
|
|
6758
|
+
projectsDir2,
|
|
6759
|
+
projectSlug,
|
|
6760
|
+
"assignments",
|
|
6761
|
+
assignmentSlug,
|
|
6762
|
+
"comments.md"
|
|
6763
|
+
);
|
|
6764
|
+
const { body, author, type, replyTo } = req.body || {};
|
|
6765
|
+
if (!body || typeof body !== "string" || !body.trim()) {
|
|
6766
|
+
res.status(400).json({ error: "body is required" });
|
|
6767
|
+
return;
|
|
6768
|
+
}
|
|
6769
|
+
const commentType = type && ["question", "note", "feedback"].includes(type) ? type : "note";
|
|
6770
|
+
const timestamp = nowTimestamp();
|
|
6771
|
+
const entryAuthor = typeof author === "string" && author.trim() ? author.trim() : "human";
|
|
6772
|
+
let currentContent;
|
|
6773
|
+
let currentCount = 0;
|
|
6774
|
+
if (await fileExists(commentsPath)) {
|
|
6775
|
+
currentContent = await readFile10(commentsPath, "utf-8");
|
|
6776
|
+
const countMatch = currentContent.match(/^entryCount:\s*(\d+)/m);
|
|
6777
|
+
if (countMatch) currentCount = parseInt(countMatch[1], 10);
|
|
6778
|
+
} else {
|
|
6779
|
+
currentContent = renderComments({
|
|
6780
|
+
assignment: assignmentSlug,
|
|
6781
|
+
timestamp
|
|
6782
|
+
});
|
|
6783
|
+
}
|
|
6784
|
+
const comment = {
|
|
6785
|
+
id: generateId().split("-")[0],
|
|
6786
|
+
timestamp,
|
|
6787
|
+
author: entryAuthor,
|
|
6788
|
+
type: commentType,
|
|
6789
|
+
body,
|
|
6790
|
+
replyTo: typeof replyTo === "string" && replyTo.trim() ? replyTo.trim() : void 0,
|
|
6791
|
+
resolved: commentType === "question" ? false : void 0
|
|
6792
|
+
};
|
|
6793
|
+
const entry = formatCommentEntry(comment);
|
|
6794
|
+
let next = setTopLevelField(currentContent, "entryCount", String(currentCount + 1));
|
|
6795
|
+
next = setTopLevelField(next, "updated", `"${timestamp}"`);
|
|
6796
|
+
if (next.includes("No comments yet.")) {
|
|
6797
|
+
next = next.replace("No comments yet.", entry.trimEnd());
|
|
6798
|
+
} else {
|
|
6799
|
+
next = `${next.trimEnd()}
|
|
6800
|
+
|
|
6801
|
+
${entry}`;
|
|
6802
|
+
}
|
|
6803
|
+
await writeFileForce(commentsPath, next);
|
|
6804
|
+
const assignment = await getAssignmentDetail(projectsDir2, projectSlug, assignmentSlug);
|
|
6805
|
+
res.status(201).json({ assignment, comment: { id: comment.id } });
|
|
6806
|
+
} catch (error) {
|
|
6807
|
+
console.error("Error appending comment:", error);
|
|
6808
|
+
res.status(500).json({ error: `Failed to append comment: ${error.message}` });
|
|
6809
|
+
}
|
|
6810
|
+
});
|
|
6811
|
+
router.patch("/api/projects/:slug/assignments/:aslug/comments/:commentId/resolved", async (req, res) => {
|
|
6812
|
+
try {
|
|
6813
|
+
const projectSlug = getParam(req.params.slug);
|
|
6814
|
+
const assignmentSlug = getParam(req.params.aslug);
|
|
6815
|
+
const commentId = getParam(req.params.commentId);
|
|
6816
|
+
const commentsPath = resolve14(
|
|
6817
|
+
projectsDir2,
|
|
6818
|
+
projectSlug,
|
|
6819
|
+
"assignments",
|
|
6820
|
+
assignmentSlug,
|
|
6821
|
+
"comments.md"
|
|
6822
|
+
);
|
|
6823
|
+
if (!await fileExists(commentsPath)) {
|
|
6824
|
+
res.status(404).json({ error: "Comments file not found" });
|
|
6825
|
+
return;
|
|
6826
|
+
}
|
|
6827
|
+
const { resolved } = req.body || {};
|
|
6828
|
+
if (typeof resolved !== "boolean") {
|
|
6829
|
+
res.status(400).json({ error: "resolved (boolean) is required" });
|
|
6830
|
+
return;
|
|
6831
|
+
}
|
|
6832
|
+
const content = await readFile10(commentsPath, "utf-8");
|
|
6833
|
+
const parsed = parseComments(content);
|
|
6834
|
+
const target = parsed.entries.find((e) => e.id === commentId);
|
|
6835
|
+
if (!target) {
|
|
6836
|
+
res.status(404).json({ error: `Comment ${commentId} not found` });
|
|
6837
|
+
return;
|
|
6838
|
+
}
|
|
6839
|
+
if (target.type !== "question") {
|
|
6840
|
+
res.status(400).json({ error: "Only questions can be resolved" });
|
|
6841
|
+
return;
|
|
6842
|
+
}
|
|
6843
|
+
const entryBlockRegex = new RegExp(
|
|
6844
|
+
`(^## ${commentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?)(\\*\\*Resolved:\\*\\*\\s*(?:true|false))`,
|
|
6845
|
+
"m"
|
|
6846
|
+
);
|
|
6847
|
+
const next = content.replace(
|
|
6848
|
+
entryBlockRegex,
|
|
6849
|
+
(_m, preamble) => `${preamble}**Resolved:** ${resolved ? "true" : "false"}`
|
|
6850
|
+
);
|
|
6851
|
+
if (next === content) {
|
|
6852
|
+
res.status(500).json({ error: "Failed to update resolved flag" });
|
|
6853
|
+
return;
|
|
6854
|
+
}
|
|
6855
|
+
const withUpdated = setTopLevelField(next, "updated", `"${nowTimestamp()}"`);
|
|
6856
|
+
await writeFileForce(commentsPath, withUpdated);
|
|
6857
|
+
const assignment = await getAssignmentDetail(projectsDir2, projectSlug, assignmentSlug);
|
|
6858
|
+
res.json({ assignment });
|
|
6859
|
+
} catch (error) {
|
|
6860
|
+
console.error("Error toggling comment resolved flag:", error);
|
|
6861
|
+
res.status(500).json({ error: `Failed to toggle resolved: ${error.message}` });
|
|
6862
|
+
}
|
|
6863
|
+
});
|
|
5857
6864
|
router.post("/api/projects/:slug/move-workspace", async (req, res) => {
|
|
5858
6865
|
try {
|
|
5859
6866
|
const projectSlug = getParam(req.params.slug);
|
|
5860
|
-
const projectPath =
|
|
6867
|
+
const projectPath = resolve14(projectsDir2, projectSlug, "project.md");
|
|
5861
6868
|
if (!await fileExists(projectPath)) {
|
|
5862
6869
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
5863
6870
|
return;
|
|
@@ -5867,7 +6874,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5867
6874
|
res.status(400).json({ error: "workspace must be a non-empty string or null (for ungrouped)." });
|
|
5868
6875
|
return;
|
|
5869
6876
|
}
|
|
5870
|
-
let content = await
|
|
6877
|
+
let content = await readFile10(projectPath, "utf-8");
|
|
5871
6878
|
content = setTopLevelField(content, "workspace", workspace ?? null);
|
|
5872
6879
|
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
5873
6880
|
await writeFileForce(projectPath, content);
|
|
@@ -5881,7 +6888,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5881
6888
|
router.post("/api/projects/:slug/status-override", async (req, res) => {
|
|
5882
6889
|
try {
|
|
5883
6890
|
const projectSlug = getParam(req.params.slug);
|
|
5884
|
-
const projectPath =
|
|
6891
|
+
const projectPath = resolve14(projectsDir2, projectSlug, "project.md");
|
|
5885
6892
|
if (!await fileExists(projectPath)) {
|
|
5886
6893
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
5887
6894
|
return;
|
|
@@ -5893,7 +6900,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5893
6900
|
res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}, or null to clear.` });
|
|
5894
6901
|
return;
|
|
5895
6902
|
}
|
|
5896
|
-
let content = await
|
|
6903
|
+
let content = await readFile10(projectPath, "utf-8");
|
|
5897
6904
|
content = setTopLevelField(content, "statusOverride", status ?? null);
|
|
5898
6905
|
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
5899
6906
|
await writeFileForce(projectPath, content);
|
|
@@ -5908,7 +6915,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5908
6915
|
try {
|
|
5909
6916
|
const projectSlug = getParam(req.params.slug);
|
|
5910
6917
|
const assignmentSlug = getParam(req.params.aslug);
|
|
5911
|
-
const assignmentPath =
|
|
6918
|
+
const assignmentPath = resolve14(
|
|
5912
6919
|
projectsDir2,
|
|
5913
6920
|
projectSlug,
|
|
5914
6921
|
"assignments",
|
|
@@ -5926,7 +6933,7 @@ function createWriteRouter(projectsDir2) {
|
|
|
5926
6933
|
res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}.` });
|
|
5927
6934
|
return;
|
|
5928
6935
|
}
|
|
5929
|
-
let content = await
|
|
6936
|
+
let content = await readFile10(assignmentPath, "utf-8");
|
|
5930
6937
|
content = setTopLevelField(content, "status", status);
|
|
5931
6938
|
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
5932
6939
|
if (status !== "blocked") {
|
|
@@ -5951,8 +6958,8 @@ function createWriteRouter(projectsDir2) {
|
|
|
5951
6958
|
res.status(400).json({ error: `Unsupported transition command "${req.params.command}"` });
|
|
5952
6959
|
return;
|
|
5953
6960
|
}
|
|
5954
|
-
const projectDir =
|
|
5955
|
-
const assignmentPath =
|
|
6961
|
+
const projectDir = resolve14(projectsDir2, projectSlug);
|
|
6962
|
+
const assignmentPath = resolve14(projectDir, "assignments", assignmentSlug, "assignment.md");
|
|
5956
6963
|
if (!await fileExists(assignmentPath)) {
|
|
5957
6964
|
res.status(404).json({ error: "Assignment not found" });
|
|
5958
6965
|
return;
|
|
@@ -5978,8 +6985,8 @@ function createWriteRouter(projectsDir2) {
|
|
|
5978
6985
|
try {
|
|
5979
6986
|
const projectSlug = getParam(req.params.slug);
|
|
5980
6987
|
const assignmentSlug = getParam(req.params.aslug);
|
|
5981
|
-
const assignmentDir =
|
|
5982
|
-
const assignmentPath =
|
|
6988
|
+
const assignmentDir = resolve14(projectsDir2, projectSlug, "assignments", assignmentSlug);
|
|
6989
|
+
const assignmentPath = resolve14(assignmentDir, "assignment.md");
|
|
5983
6990
|
if (!await fileExists(assignmentPath)) {
|
|
5984
6991
|
res.status(404).json({ error: `Assignment "${assignmentSlug}" not found in project "${projectSlug}"` });
|
|
5985
6992
|
return;
|
|
@@ -5991,388 +6998,686 @@ function createWriteRouter(projectsDir2) {
|
|
|
5991
6998
|
res.status(500).json({ error: `Failed to delete assignment: ${error.message}` });
|
|
5992
6999
|
}
|
|
5993
7000
|
});
|
|
5994
|
-
|
|
5995
|
-
}
|
|
5996
|
-
|
|
5997
|
-
// src/dashboard/api-servers.ts
|
|
5998
|
-
init_servers();
|
|
5999
|
-
init_scanner();
|
|
6000
|
-
import { Router as Router2 } from "express";
|
|
6001
|
-
function createServersRouter(serversDir2, projectsDir2) {
|
|
6002
|
-
const router = Router2();
|
|
6003
|
-
router.get("/", async (_req, res) => {
|
|
7001
|
+
router.post("/api/assignments", async (req, res) => {
|
|
6004
7002
|
try {
|
|
6005
|
-
|
|
6006
|
-
|
|
7003
|
+
if (!assignmentsDir2) {
|
|
7004
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7005
|
+
return;
|
|
7006
|
+
}
|
|
7007
|
+
const { title, slug, priority, type } = req.body || {};
|
|
7008
|
+
if (!title || typeof title !== "string" || !title.trim()) {
|
|
7009
|
+
res.status(400).json({ error: "title is required" });
|
|
7010
|
+
return;
|
|
7011
|
+
}
|
|
7012
|
+
const { dependsOn } = req.body || {};
|
|
7013
|
+
if (Array.isArray(dependsOn) && dependsOn.length > 0) {
|
|
7014
|
+
res.status(400).json({ error: "Standalone assignments cannot declare dependsOn." });
|
|
7015
|
+
return;
|
|
7016
|
+
}
|
|
7017
|
+
const id = generateId();
|
|
7018
|
+
const assignmentDir = resolve14(assignmentsDir2, id);
|
|
7019
|
+
if (await fileExists(assignmentDir)) {
|
|
7020
|
+
res.status(500).json({ error: "UUID collision \u2014 try again" });
|
|
7021
|
+
return;
|
|
7022
|
+
}
|
|
7023
|
+
const timestamp = nowTimestamp();
|
|
7024
|
+
const resolvedSlug = typeof slug === "string" && slug.trim() ? slug.trim() : slugifyLocal(title);
|
|
7025
|
+
const resolvedPriority = typeof priority === "string" && ["low", "medium", "high", "critical"].includes(priority) ? priority : "medium";
|
|
7026
|
+
await ensureDir(assignmentDir);
|
|
7027
|
+
const assignmentContent = renderAssignment({
|
|
7028
|
+
id,
|
|
7029
|
+
slug: resolvedSlug,
|
|
7030
|
+
title: title.trim(),
|
|
7031
|
+
timestamp,
|
|
7032
|
+
priority: resolvedPriority,
|
|
7033
|
+
dependsOn: [],
|
|
7034
|
+
links: [],
|
|
7035
|
+
project: null,
|
|
7036
|
+
type: typeof type === "string" ? type : void 0
|
|
7037
|
+
});
|
|
7038
|
+
await writeFileForce(resolve14(assignmentDir, "assignment.md"), assignmentContent);
|
|
7039
|
+
await writeFileForce(
|
|
7040
|
+
resolve14(assignmentDir, "scratchpad.md"),
|
|
7041
|
+
renderScratchpad({ assignmentSlug: id, timestamp })
|
|
7042
|
+
);
|
|
7043
|
+
await writeFileForce(
|
|
7044
|
+
resolve14(assignmentDir, "handoff.md"),
|
|
7045
|
+
renderHandoff({ assignmentSlug: id, timestamp })
|
|
7046
|
+
);
|
|
7047
|
+
await writeFileForce(
|
|
7048
|
+
resolve14(assignmentDir, "decision-record.md"),
|
|
7049
|
+
renderDecisionRecord({ assignmentSlug: id, timestamp })
|
|
7050
|
+
);
|
|
7051
|
+
await writeFileForce(
|
|
7052
|
+
resolve14(assignmentDir, "progress.md"),
|
|
7053
|
+
renderProgress({ assignment: id, timestamp })
|
|
7054
|
+
);
|
|
7055
|
+
await writeFileForce(
|
|
7056
|
+
resolve14(assignmentDir, "comments.md"),
|
|
7057
|
+
renderComments({ assignment: id, timestamp })
|
|
7058
|
+
);
|
|
7059
|
+
const detail = await getAssignmentDetailById(projectsDir2, assignmentsDir2, id);
|
|
7060
|
+
res.status(201).json({ assignment: detail });
|
|
6007
7061
|
} catch (error) {
|
|
6008
|
-
|
|
7062
|
+
console.error("Error creating standalone assignment:", error);
|
|
7063
|
+
res.status(500).json({ error: `Failed to create standalone assignment: ${error.message}` });
|
|
6009
7064
|
}
|
|
6010
7065
|
});
|
|
6011
|
-
router.
|
|
7066
|
+
router.post("/api/assignments/:id/comments", async (req, res) => {
|
|
6012
7067
|
try {
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
|
|
7068
|
+
if (!assignmentsDir2) {
|
|
7069
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7070
|
+
return;
|
|
7071
|
+
}
|
|
7072
|
+
const id = getParam(req.params.id);
|
|
7073
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, id);
|
|
7074
|
+
if (!resolved) {
|
|
7075
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
7076
|
+
return;
|
|
7077
|
+
}
|
|
7078
|
+
await appendCommentTo(resolved.assignmentDir, resolved.standalone ? resolved.id : resolved.assignmentSlug, req, res, async () => {
|
|
7079
|
+
return resolved.standalone ? getAssignmentDetailById(projectsDir2, assignmentsDir2, id) : getAssignmentDetail(projectsDir2, resolved.projectSlug, resolved.assignmentSlug);
|
|
7080
|
+
});
|
|
7081
|
+
} catch (error) {
|
|
7082
|
+
console.error("Error appending comment (by id):", error);
|
|
7083
|
+
res.status(500).json({ error: `Failed to append comment: ${error.message}` });
|
|
7084
|
+
}
|
|
7085
|
+
});
|
|
7086
|
+
router.patch("/api/assignments/:id/comments/:commentId/resolved", async (req, res) => {
|
|
7087
|
+
try {
|
|
7088
|
+
if (!assignmentsDir2) {
|
|
7089
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7090
|
+
return;
|
|
7091
|
+
}
|
|
7092
|
+
const id = getParam(req.params.id);
|
|
7093
|
+
const commentId = getParam(req.params.commentId);
|
|
7094
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, id);
|
|
7095
|
+
if (!resolved) {
|
|
7096
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
7097
|
+
return;
|
|
7098
|
+
}
|
|
7099
|
+
await toggleCommentResolvedAt(resolved.assignmentDir, commentId, req, res, async () => {
|
|
7100
|
+
return resolved.standalone ? getAssignmentDetailById(projectsDir2, assignmentsDir2, id) : getAssignmentDetail(projectsDir2, resolved.projectSlug, resolved.assignmentSlug);
|
|
7101
|
+
});
|
|
7102
|
+
} catch (error) {
|
|
7103
|
+
console.error("Error toggling comment resolved (by id):", error);
|
|
7104
|
+
res.status(500).json({ error: `Failed to toggle resolved: ${error.message}` });
|
|
7105
|
+
}
|
|
7106
|
+
});
|
|
7107
|
+
router.get("/api/assignments/:id/edit", async (req, res) => {
|
|
7108
|
+
if (!assignmentsDir2) {
|
|
7109
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7110
|
+
return;
|
|
7111
|
+
}
|
|
7112
|
+
const id = getParam(req.params.id);
|
|
7113
|
+
const doc = await getEditableDocumentById(projectsDir2, assignmentsDir2, "assignment", id);
|
|
7114
|
+
if (!doc) {
|
|
7115
|
+
res.status(404).json({ error: "Assignment not found" });
|
|
7116
|
+
return;
|
|
7117
|
+
}
|
|
7118
|
+
res.json(doc);
|
|
7119
|
+
});
|
|
7120
|
+
router.get("/api/assignments/:id/plan/edit", async (req, res) => {
|
|
7121
|
+
if (!assignmentsDir2) {
|
|
7122
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7123
|
+
return;
|
|
7124
|
+
}
|
|
7125
|
+
const id = getParam(req.params.id);
|
|
7126
|
+
const doc = await getEditableDocumentById(projectsDir2, assignmentsDir2, "plan", id);
|
|
7127
|
+
if (!doc) {
|
|
7128
|
+
res.status(404).json({ error: "Plan not found" });
|
|
7129
|
+
return;
|
|
7130
|
+
}
|
|
7131
|
+
res.json(doc);
|
|
7132
|
+
});
|
|
7133
|
+
router.get("/api/assignments/:id/scratchpad/edit", async (req, res) => {
|
|
7134
|
+
if (!assignmentsDir2) {
|
|
7135
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7136
|
+
return;
|
|
7137
|
+
}
|
|
7138
|
+
const id = getParam(req.params.id);
|
|
7139
|
+
const doc = await getEditableDocumentById(projectsDir2, assignmentsDir2, "scratchpad", id);
|
|
7140
|
+
if (!doc) {
|
|
7141
|
+
res.status(404).json({ error: "Scratchpad not found" });
|
|
7142
|
+
return;
|
|
7143
|
+
}
|
|
7144
|
+
res.json(doc);
|
|
7145
|
+
});
|
|
7146
|
+
router.get("/api/assignments/:id/handoff/edit", async (req, res) => {
|
|
7147
|
+
if (!assignmentsDir2) {
|
|
7148
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7149
|
+
return;
|
|
7150
|
+
}
|
|
7151
|
+
const id = getParam(req.params.id);
|
|
7152
|
+
const doc = await getEditableDocumentById(projectsDir2, assignmentsDir2, "handoff", id);
|
|
7153
|
+
if (!doc) {
|
|
7154
|
+
res.status(404).json({ error: "Handoff log not found" });
|
|
7155
|
+
return;
|
|
7156
|
+
}
|
|
7157
|
+
res.json(doc);
|
|
7158
|
+
});
|
|
7159
|
+
router.get("/api/assignments/:id/decision-record/edit", async (req, res) => {
|
|
7160
|
+
if (!assignmentsDir2) {
|
|
7161
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7162
|
+
return;
|
|
7163
|
+
}
|
|
7164
|
+
const id = getParam(req.params.id);
|
|
7165
|
+
const doc = await getEditableDocumentById(projectsDir2, assignmentsDir2, "decision-record", id);
|
|
7166
|
+
if (!doc) {
|
|
7167
|
+
res.status(404).json({ error: "Decision record not found" });
|
|
7168
|
+
return;
|
|
7169
|
+
}
|
|
7170
|
+
res.json(doc);
|
|
7171
|
+
});
|
|
7172
|
+
router.patch("/api/assignments/:id", async (req, res) => {
|
|
7173
|
+
try {
|
|
7174
|
+
if (!assignmentsDir2) {
|
|
7175
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7176
|
+
return;
|
|
7177
|
+
}
|
|
7178
|
+
const id = getParam(req.params.id);
|
|
7179
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, id);
|
|
7180
|
+
if (!resolved) {
|
|
7181
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
7182
|
+
return;
|
|
7183
|
+
}
|
|
7184
|
+
const assignmentPath = resolve14(resolved.assignmentDir, "assignment.md");
|
|
7185
|
+
const currentContent = await readCurrentDocument(assignmentPath);
|
|
7186
|
+
if (!currentContent) {
|
|
7187
|
+
res.status(404).json({ error: "Assignment not found" });
|
|
6016
7188
|
return;
|
|
6017
7189
|
}
|
|
6018
|
-
res
|
|
7190
|
+
const nextContentRaw = requireContent(req, res);
|
|
7191
|
+
if (!nextContentRaw) return;
|
|
7192
|
+
const current = parseAssignmentFull(currentContent);
|
|
7193
|
+
const next = parseAssignmentFull(nextContentRaw);
|
|
7194
|
+
if (!next.title) {
|
|
7195
|
+
res.status(400).json({ error: "Assignment content must include a title." });
|
|
7196
|
+
return;
|
|
7197
|
+
}
|
|
7198
|
+
let nextContent = nextContentRaw;
|
|
7199
|
+
if (current.id) nextContent = setTopLevelField(nextContent, "id", current.id);
|
|
7200
|
+
nextContent = setTopLevelField(nextContent, "project", null);
|
|
7201
|
+
if (current.slug) nextContent = setTopLevelField(nextContent, "slug", current.slug);
|
|
7202
|
+
if (next.status !== current.status && current.status === "blocked" && next.status !== "blocked") {
|
|
7203
|
+
nextContent = setTopLevelField(nextContent, "blockedReason", null);
|
|
7204
|
+
}
|
|
7205
|
+
nextContent = setTopLevelField(nextContent, "updated", nowTimestamp());
|
|
7206
|
+
await writeFileForce(assignmentPath, nextContent);
|
|
7207
|
+
const assignment = await getAssignmentDetailById(projectsDir2, assignmentsDir2, id);
|
|
7208
|
+
res.json({ assignment, content: nextContent });
|
|
6019
7209
|
} catch (error) {
|
|
6020
|
-
|
|
7210
|
+
console.error("Error updating standalone assignment:", error);
|
|
7211
|
+
res.status(500).json({ error: `Failed to update assignment: ${error.message}` });
|
|
6021
7212
|
}
|
|
6022
7213
|
});
|
|
6023
|
-
router.
|
|
7214
|
+
router.patch("/api/assignments/:id/plan", async (req, res) => {
|
|
6024
7215
|
try {
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
res.status(400).json({ error: "name is required" });
|
|
7216
|
+
if (!assignmentsDir2) {
|
|
7217
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
6028
7218
|
return;
|
|
6029
7219
|
}
|
|
6030
|
-
const
|
|
6031
|
-
const
|
|
6032
|
-
if (
|
|
6033
|
-
res.status(
|
|
7220
|
+
const id = getParam(req.params.id);
|
|
7221
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, id);
|
|
7222
|
+
if (!resolved) {
|
|
7223
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
6034
7224
|
return;
|
|
6035
7225
|
}
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
res.status(500).json({ error: error instanceof Error ? error.message : "Registration failed" });
|
|
6041
|
-
}
|
|
6042
|
-
});
|
|
6043
|
-
router.delete("/:name", async (req, res) => {
|
|
6044
|
-
try {
|
|
6045
|
-
const data = await readSessionFile(serversDir2, req.params.name);
|
|
6046
|
-
if (!data) {
|
|
6047
|
-
res.status(404).json({ error: "Session not found" });
|
|
7226
|
+
const planPath = resolve14(resolved.assignmentDir, "plan.md");
|
|
7227
|
+
const currentContent = await readCurrentDocument(planPath);
|
|
7228
|
+
if (!currentContent) {
|
|
7229
|
+
res.status(404).json({ error: "Plan not found" });
|
|
6048
7230
|
return;
|
|
6049
7231
|
}
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
7232
|
+
const nextContentRaw = requireContent(req, res);
|
|
7233
|
+
if (!nextContentRaw) return;
|
|
7234
|
+
const parsed = parsePlan(nextContentRaw);
|
|
7235
|
+
if (!parsed.assignment) {
|
|
7236
|
+
res.status(400).json({ error: "Plan content must include the assignment field." });
|
|
7237
|
+
return;
|
|
7238
|
+
}
|
|
7239
|
+
const nextContent = setTopLevelField(nextContentRaw, "updated", nowTimestamp());
|
|
7240
|
+
await writeFileForce(planPath, nextContent);
|
|
7241
|
+
const assignment = await getAssignmentDetailById(projectsDir2, assignmentsDir2, id);
|
|
7242
|
+
res.json({ assignment, content: nextContent });
|
|
6053
7243
|
} catch (error) {
|
|
6054
|
-
|
|
7244
|
+
console.error("Error updating standalone plan:", error);
|
|
7245
|
+
res.status(500).json({ error: `Failed to update plan: ${error.message}` });
|
|
6055
7246
|
}
|
|
6056
7247
|
});
|
|
6057
|
-
router.
|
|
7248
|
+
router.patch("/api/assignments/:id/scratchpad", async (req, res) => {
|
|
6058
7249
|
try {
|
|
6059
|
-
|
|
6060
|
-
|
|
6061
|
-
|
|
7250
|
+
if (!assignmentsDir2) {
|
|
7251
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7252
|
+
return;
|
|
6062
7253
|
}
|
|
6063
|
-
|
|
6064
|
-
const
|
|
6065
|
-
|
|
7254
|
+
const id = getParam(req.params.id);
|
|
7255
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, id);
|
|
7256
|
+
if (!resolved) {
|
|
7257
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
7258
|
+
return;
|
|
7259
|
+
}
|
|
7260
|
+
const scratchpadPath = resolve14(resolved.assignmentDir, "scratchpad.md");
|
|
7261
|
+
const currentContent = await readCurrentDocument(scratchpadPath);
|
|
7262
|
+
if (!currentContent) {
|
|
7263
|
+
res.status(404).json({ error: "Scratchpad not found" });
|
|
7264
|
+
return;
|
|
7265
|
+
}
|
|
7266
|
+
const nextContentRaw = requireContent(req, res);
|
|
7267
|
+
if (!nextContentRaw) return;
|
|
7268
|
+
const parsed = parseScratchpad(nextContentRaw);
|
|
7269
|
+
if (!parsed.assignment) {
|
|
7270
|
+
res.status(400).json({ error: "Scratchpad content must include the assignment field." });
|
|
7271
|
+
return;
|
|
7272
|
+
}
|
|
7273
|
+
const nextContent = setTopLevelField(nextContentRaw, "updated", nowTimestamp());
|
|
7274
|
+
await writeFileForce(scratchpadPath, nextContent);
|
|
7275
|
+
const assignment = await getAssignmentDetailById(projectsDir2, assignmentsDir2, id);
|
|
7276
|
+
res.json({ assignment, content: nextContent });
|
|
6066
7277
|
} catch (error) {
|
|
6067
|
-
|
|
7278
|
+
console.error("Error updating standalone scratchpad:", error);
|
|
7279
|
+
res.status(500).json({ error: `Failed to update scratchpad: ${error.message}` });
|
|
6068
7280
|
}
|
|
6069
7281
|
});
|
|
6070
|
-
router.post("/:
|
|
7282
|
+
router.post("/api/assignments/:id/handoff/entries", async (req, res) => {
|
|
6071
7283
|
try {
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
res.status(404).json({ error: "Session not found" });
|
|
7284
|
+
if (!assignmentsDir2) {
|
|
7285
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
6075
7286
|
return;
|
|
6076
7287
|
}
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
7288
|
+
const id = getParam(req.params.id);
|
|
7289
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, id);
|
|
7290
|
+
if (!resolved) {
|
|
7291
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
7292
|
+
return;
|
|
7293
|
+
}
|
|
7294
|
+
const handoffPath = resolve14(resolved.assignmentDir, "handoff.md");
|
|
7295
|
+
const currentContent = await readCurrentDocument(handoffPath);
|
|
7296
|
+
if (!currentContent) {
|
|
7297
|
+
res.status(404).json({ error: "Handoff log not found" });
|
|
7298
|
+
return;
|
|
7299
|
+
}
|
|
7300
|
+
const { title, body } = req.body || {};
|
|
7301
|
+
if (!body || typeof body !== "string" || !body.trim()) {
|
|
7302
|
+
res.status(400).json({ error: "body is required" });
|
|
7303
|
+
return;
|
|
7304
|
+
}
|
|
7305
|
+
const parsed = parseHandoff(currentContent);
|
|
7306
|
+
const nextContent = appendLogEntry(
|
|
7307
|
+
currentContent,
|
|
7308
|
+
"handoffCount",
|
|
7309
|
+
parsed.handoffCount + 1,
|
|
7310
|
+
title && typeof title === "string" && title.trim() ? title.trim() : `Handoff ${parsed.handoffCount + 1}`,
|
|
7311
|
+
body,
|
|
7312
|
+
"No handoffs recorded yet."
|
|
7313
|
+
);
|
|
7314
|
+
await writeFileForce(handoffPath, nextContent);
|
|
7315
|
+
const assignment = await getAssignmentDetailById(projectsDir2, assignmentsDir2, id);
|
|
7316
|
+
res.status(201).json({ assignment, content: nextContent });
|
|
6081
7317
|
} catch (error) {
|
|
6082
|
-
|
|
7318
|
+
console.error("Error appending standalone handoff entry:", error);
|
|
7319
|
+
res.status(500).json({ error: `Failed to append handoff entry: ${error.message}` });
|
|
6083
7320
|
}
|
|
6084
7321
|
});
|
|
6085
|
-
router.
|
|
7322
|
+
router.post("/api/assignments/:id/decision-record/entries", async (req, res) => {
|
|
6086
7323
|
try {
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
if (!data) {
|
|
6090
|
-
res.status(404).json({ error: "Session not found" });
|
|
7324
|
+
if (!assignmentsDir2) {
|
|
7325
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
6091
7326
|
return;
|
|
6092
7327
|
}
|
|
6093
|
-
const
|
|
6094
|
-
|
|
6095
|
-
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
parseInt(windowIndex, 10),
|
|
6099
|
-
parseInt(paneIndex, 10),
|
|
6100
|
-
body
|
|
6101
|
-
);
|
|
6102
|
-
clearScanCache();
|
|
6103
|
-
res.json({ updated: true });
|
|
6104
|
-
} else {
|
|
6105
|
-
res.status(400).json({ error: "Body must be { project, assignment } or null" });
|
|
7328
|
+
const id = getParam(req.params.id);
|
|
7329
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, id);
|
|
7330
|
+
if (!resolved) {
|
|
7331
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
7332
|
+
return;
|
|
6106
7333
|
}
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
}
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
6133
|
-
CREATE TABLE IF NOT EXISTS sessions (
|
|
6134
|
-
session_id TEXT PRIMARY KEY,
|
|
6135
|
-
project_slug TEXT,
|
|
6136
|
-
assignment_slug TEXT,
|
|
6137
|
-
agent TEXT NOT NULL,
|
|
6138
|
-
started TEXT NOT NULL,
|
|
6139
|
-
ended TEXT,
|
|
6140
|
-
status TEXT NOT NULL DEFAULT 'active',
|
|
6141
|
-
path TEXT,
|
|
6142
|
-
description TEXT,
|
|
6143
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
6144
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
6145
|
-
);
|
|
6146
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
|
|
6147
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
|
|
6148
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
6149
|
-
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
6150
|
-
`;
|
|
6151
|
-
function initSessionDb(dbPath) {
|
|
6152
|
-
if (db) return db;
|
|
6153
|
-
const finalPath = dbPath ?? resolve12(syntaurRoot(), "syntaur.db");
|
|
6154
|
-
db = new Database(finalPath);
|
|
6155
|
-
db.pragma("journal_mode = WAL");
|
|
6156
|
-
db.exec(SCHEMA_SQL);
|
|
6157
|
-
db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
|
|
6158
|
-
"schema_version",
|
|
6159
|
-
SCHEMA_VERSION
|
|
6160
|
-
);
|
|
6161
|
-
const currentVersion = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
6162
|
-
if (currentVersion?.value === "1") {
|
|
6163
|
-
db.exec(`
|
|
6164
|
-
CREATE TABLE sessions_v2 (
|
|
6165
|
-
session_id TEXT PRIMARY KEY,
|
|
6166
|
-
project_slug TEXT,
|
|
6167
|
-
assignment_slug TEXT,
|
|
6168
|
-
agent TEXT NOT NULL,
|
|
6169
|
-
started TEXT NOT NULL,
|
|
6170
|
-
ended TEXT,
|
|
6171
|
-
status TEXT NOT NULL DEFAULT 'active',
|
|
6172
|
-
path TEXT,
|
|
6173
|
-
description TEXT,
|
|
6174
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
6175
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
6176
|
-
);
|
|
6177
|
-
INSERT INTO sessions_v2 SELECT session_id, project_slug, assignment_slug, agent, started, ended, status, path, NULL, created_at, updated_at FROM sessions;
|
|
6178
|
-
DROP TABLE sessions;
|
|
6179
|
-
ALTER TABLE sessions_v2 RENAME TO sessions;
|
|
6180
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_slug);
|
|
6181
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_assignment ON sessions(project_slug, assignment_slug);
|
|
6182
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
6183
|
-
UPDATE meta SET value = '2' WHERE key = 'schema_version';
|
|
6184
|
-
`);
|
|
6185
|
-
}
|
|
6186
|
-
return db;
|
|
6187
|
-
}
|
|
6188
|
-
function getSessionDb() {
|
|
6189
|
-
if (!db) {
|
|
6190
|
-
throw new Error(
|
|
6191
|
-
"Session database not initialized. Call initSessionDb() first."
|
|
6192
|
-
);
|
|
6193
|
-
}
|
|
6194
|
-
return db;
|
|
6195
|
-
}
|
|
6196
|
-
function closeSessionDb() {
|
|
6197
|
-
if (db) {
|
|
6198
|
-
db.close();
|
|
6199
|
-
db = null;
|
|
6200
|
-
}
|
|
6201
|
-
}
|
|
6202
|
-
async function migrateFromMarkdown(projectsDir2) {
|
|
6203
|
-
const database = getSessionDb();
|
|
6204
|
-
const count = database.prepare("SELECT COUNT(*) as count FROM sessions").get();
|
|
6205
|
-
if (count.count > 0) return 0;
|
|
6206
|
-
if (!await fileExists(projectsDir2)) return 0;
|
|
6207
|
-
const entries = await readdir6(projectsDir2, { withFileTypes: true });
|
|
6208
|
-
const allSessions = [];
|
|
6209
|
-
for (const entry of entries) {
|
|
6210
|
-
if (!entry.isDirectory()) continue;
|
|
6211
|
-
const projectDir = resolve12(projectsDir2, entry.name);
|
|
6212
|
-
const indexPath = resolve12(projectDir, "_index-sessions.md");
|
|
6213
|
-
if (!await fileExists(indexPath)) continue;
|
|
6214
|
-
const sessions = await parseMarkdownSessionsIndex(indexPath, entry.name);
|
|
6215
|
-
allSessions.push(...sessions);
|
|
6216
|
-
}
|
|
6217
|
-
if (allSessions.length === 0) return 0;
|
|
6218
|
-
const insert = database.prepare(`
|
|
6219
|
-
INSERT OR IGNORE INTO sessions (session_id, project_slug, assignment_slug, agent, started, status, path)
|
|
6220
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
6221
|
-
`);
|
|
6222
|
-
const insertAll = database.transaction((sessions) => {
|
|
6223
|
-
for (const s of sessions) {
|
|
6224
|
-
insert.run(s.sessionId, s.projectSlug, s.assignmentSlug, s.agent, s.started, s.status, s.path);
|
|
7334
|
+
const decisionPath = resolve14(resolved.assignmentDir, "decision-record.md");
|
|
7335
|
+
const currentContent = await readCurrentDocument(decisionPath);
|
|
7336
|
+
if (!currentContent) {
|
|
7337
|
+
res.status(404).json({ error: "Decision record not found" });
|
|
7338
|
+
return;
|
|
7339
|
+
}
|
|
7340
|
+
const { title, body } = req.body || {};
|
|
7341
|
+
if (!body || typeof body !== "string" || !body.trim()) {
|
|
7342
|
+
res.status(400).json({ error: "body is required" });
|
|
7343
|
+
return;
|
|
7344
|
+
}
|
|
7345
|
+
const parsed = parseDecisionRecord(currentContent);
|
|
7346
|
+
const nextContent = appendLogEntry(
|
|
7347
|
+
currentContent,
|
|
7348
|
+
"decisionCount",
|
|
7349
|
+
parsed.decisionCount + 1,
|
|
7350
|
+
title && typeof title === "string" && title.trim() ? title.trim() : `Decision ${parsed.decisionCount + 1}`,
|
|
7351
|
+
body,
|
|
7352
|
+
"No decisions recorded yet."
|
|
7353
|
+
);
|
|
7354
|
+
await writeFileForce(decisionPath, nextContent);
|
|
7355
|
+
const assignment = await getAssignmentDetailById(projectsDir2, assignmentsDir2, id);
|
|
7356
|
+
res.status(201).json({ assignment, content: nextContent });
|
|
7357
|
+
} catch (error) {
|
|
7358
|
+
console.error("Error appending standalone decision entry:", error);
|
|
7359
|
+
res.status(500).json({ error: `Failed to append decision entry: ${error.message}` });
|
|
6225
7360
|
}
|
|
6226
7361
|
});
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
}
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
7362
|
+
router.post("/api/assignments/:id/status-override", async (req, res) => {
|
|
7363
|
+
try {
|
|
7364
|
+
if (!assignmentsDir2) {
|
|
7365
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7366
|
+
return;
|
|
7367
|
+
}
|
|
7368
|
+
const id = getParam(req.params.id);
|
|
7369
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, id);
|
|
7370
|
+
if (!resolved) {
|
|
7371
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
7372
|
+
return;
|
|
7373
|
+
}
|
|
7374
|
+
const assignmentPath = resolve14(resolved.assignmentDir, "assignment.md");
|
|
7375
|
+
if (!await fileExists(assignmentPath)) {
|
|
7376
|
+
res.status(404).json({ error: "Assignment not found" });
|
|
7377
|
+
return;
|
|
7378
|
+
}
|
|
7379
|
+
const { status } = req.body || {};
|
|
7380
|
+
const config = await getStatusConfig();
|
|
7381
|
+
const validStatuses = config.statuses.map((s) => s.id);
|
|
7382
|
+
if (typeof status !== "string" || !validStatuses.includes(status)) {
|
|
7383
|
+
res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}.` });
|
|
7384
|
+
return;
|
|
7385
|
+
}
|
|
7386
|
+
let content = await readFile10(assignmentPath, "utf-8");
|
|
7387
|
+
content = setTopLevelField(content, "status", status);
|
|
7388
|
+
content = setTopLevelField(content, "updated", nowTimestamp());
|
|
7389
|
+
if (status !== "blocked") {
|
|
7390
|
+
content = setTopLevelField(content, "blockedReason", null);
|
|
7391
|
+
}
|
|
7392
|
+
await writeFileForce(assignmentPath, content);
|
|
7393
|
+
const assignment = await getAssignmentDetailById(projectsDir2, assignmentsDir2, id);
|
|
7394
|
+
res.json({ assignment });
|
|
7395
|
+
} catch (error) {
|
|
7396
|
+
console.error("Error overriding standalone status:", error);
|
|
7397
|
+
res.status(500).json({ error: `Failed to override status: ${error.message}` });
|
|
6245
7398
|
}
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
7399
|
+
});
|
|
7400
|
+
router.patch("/api/assignments/:id/acceptance-criteria/:index", async (req, res) => {
|
|
7401
|
+
try {
|
|
7402
|
+
if (!assignmentsDir2) {
|
|
7403
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7404
|
+
return;
|
|
7405
|
+
}
|
|
7406
|
+
const id = getParam(req.params.id);
|
|
7407
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, id);
|
|
7408
|
+
if (!resolved) {
|
|
7409
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
7410
|
+
return;
|
|
7411
|
+
}
|
|
7412
|
+
const assignmentPath = resolve14(resolved.assignmentDir, "assignment.md");
|
|
7413
|
+
const currentContent = await readCurrentDocument(assignmentPath);
|
|
7414
|
+
if (!currentContent) {
|
|
7415
|
+
res.status(404).json({ error: "Assignment not found" });
|
|
7416
|
+
return;
|
|
7417
|
+
}
|
|
7418
|
+
const { checked } = req.body || {};
|
|
7419
|
+
if (typeof checked !== "boolean") {
|
|
7420
|
+
res.status(400).json({ error: "checked must be a boolean" });
|
|
7421
|
+
return;
|
|
7422
|
+
}
|
|
7423
|
+
const index = Number.parseInt(getParam(req.params.index), 10);
|
|
7424
|
+
const result = toggleAcceptanceCriterion(currentContent, index, checked);
|
|
7425
|
+
if ("error" in result) {
|
|
7426
|
+
res.status(400).json({ error: result.error });
|
|
7427
|
+
return;
|
|
7428
|
+
}
|
|
7429
|
+
const nextContent = setTopLevelField(result.content, "updated", nowTimestamp());
|
|
7430
|
+
await writeFileForce(assignmentPath, nextContent);
|
|
7431
|
+
const assignment = await getAssignmentDetailById(projectsDir2, assignmentsDir2, id);
|
|
7432
|
+
res.json({ assignment, content: nextContent });
|
|
7433
|
+
} catch (error) {
|
|
7434
|
+
console.error("Error toggling standalone acceptance criterion:", error);
|
|
7435
|
+
res.status(500).json({ error: `Failed to toggle acceptance criterion: ${error.message}` });
|
|
6249
7436
|
}
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
});
|
|
7437
|
+
});
|
|
7438
|
+
router.post("/api/assignments/:id/transitions/:command", async (req, res) => {
|
|
7439
|
+
try {
|
|
7440
|
+
if (!assignmentsDir2) {
|
|
7441
|
+
res.status(501).json({ error: "Standalone assignments not configured on this server" });
|
|
7442
|
+
return;
|
|
7443
|
+
}
|
|
7444
|
+
const id = getParam(req.params.id);
|
|
7445
|
+
const command = getParam(req.params.command);
|
|
7446
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, id);
|
|
7447
|
+
if (!resolved) {
|
|
7448
|
+
res.status(404).json({ error: `Assignment "${id}" not found` });
|
|
7449
|
+
return;
|
|
7450
|
+
}
|
|
7451
|
+
const { reason } = req.body || {};
|
|
7452
|
+
const transitionResult = await executeTransitionByDir(
|
|
7453
|
+
resolved.assignmentDir,
|
|
7454
|
+
command,
|
|
7455
|
+
{
|
|
7456
|
+
standalone: resolved.standalone,
|
|
7457
|
+
reason: typeof reason === "string" ? reason : void 0
|
|
7458
|
+
}
|
|
7459
|
+
);
|
|
7460
|
+
if (!transitionResult.success) {
|
|
7461
|
+
res.status(400).json({ error: transitionResult.message, fromStatus: transitionResult.fromStatus });
|
|
7462
|
+
return;
|
|
6262
7463
|
}
|
|
7464
|
+
const detail = resolved.standalone ? await getAssignmentDetailById(projectsDir2, assignmentsDir2, id) : await getAssignmentDetail(projectsDir2, resolved.projectSlug, resolved.assignmentSlug);
|
|
7465
|
+
res.json({ assignment: detail, warnings: transitionResult.warnings ?? [] });
|
|
7466
|
+
} catch (error) {
|
|
7467
|
+
console.error("Error transitioning by id:", error);
|
|
7468
|
+
res.status(500).json({ error: `Failed to transition: ${error.message}` });
|
|
6263
7469
|
}
|
|
6264
|
-
}
|
|
6265
|
-
return
|
|
7470
|
+
});
|
|
7471
|
+
return router;
|
|
6266
7472
|
}
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
|
|
7473
|
+
function slugifyLocal(input2) {
|
|
7474
|
+
return input2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "untitled";
|
|
7475
|
+
}
|
|
7476
|
+
async function appendCommentTo(assignmentDir, assignmentRef, req, res, reloadDetail) {
|
|
7477
|
+
const commentsPath = resolve14(assignmentDir, "comments.md");
|
|
7478
|
+
const { body, author, type, replyTo } = req.body || {};
|
|
7479
|
+
if (!body || typeof body !== "string" || !body.trim()) {
|
|
7480
|
+
res.status(400).json({ error: "body is required" });
|
|
7481
|
+
return;
|
|
7482
|
+
}
|
|
7483
|
+
const commentType = type && ["question", "note", "feedback"].includes(type) ? type : "note";
|
|
7484
|
+
const timestamp = nowTimestamp();
|
|
7485
|
+
const entryAuthor = typeof author === "string" && author.trim() ? author.trim() : "human";
|
|
7486
|
+
let currentContent;
|
|
7487
|
+
let currentCount = 0;
|
|
7488
|
+
if (await fileExists(commentsPath)) {
|
|
7489
|
+
currentContent = await readFile10(commentsPath, "utf-8");
|
|
7490
|
+
const countMatch = currentContent.match(/^entryCount:\s*(\d+)/m);
|
|
7491
|
+
if (countMatch) currentCount = parseInt(countMatch[1], 10);
|
|
7492
|
+
} else {
|
|
7493
|
+
currentContent = renderComments({ assignment: assignmentRef, timestamp });
|
|
7494
|
+
}
|
|
7495
|
+
const comment = {
|
|
7496
|
+
id: generateId().split("-")[0],
|
|
7497
|
+
timestamp,
|
|
7498
|
+
author: entryAuthor,
|
|
7499
|
+
type: commentType,
|
|
7500
|
+
body,
|
|
7501
|
+
replyTo: typeof replyTo === "string" && replyTo.trim() ? replyTo.trim() : void 0,
|
|
7502
|
+
resolved: commentType === "question" ? false : void 0
|
|
6280
7503
|
};
|
|
7504
|
+
const entry = formatCommentEntry(comment);
|
|
7505
|
+
let next = setTopLevelField(currentContent, "entryCount", String(currentCount + 1));
|
|
7506
|
+
next = setTopLevelField(next, "updated", `"${timestamp}"`);
|
|
7507
|
+
if (next.includes("No comments yet.")) {
|
|
7508
|
+
next = next.replace("No comments yet.", entry.trimEnd());
|
|
7509
|
+
} else {
|
|
7510
|
+
next = `${next.trimEnd()}
|
|
7511
|
+
|
|
7512
|
+
${entry}`;
|
|
7513
|
+
}
|
|
7514
|
+
await writeFileForce(commentsPath, next);
|
|
7515
|
+
const assignment = await reloadDetail();
|
|
7516
|
+
res.status(201).json({ assignment, comment: { id: comment.id } });
|
|
6281
7517
|
}
|
|
6282
|
-
async function
|
|
6283
|
-
const
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
7518
|
+
async function toggleCommentResolvedAt(assignmentDir, commentId, req, res, reloadDetail) {
|
|
7519
|
+
const commentsPath = resolve14(assignmentDir, "comments.md");
|
|
7520
|
+
if (!await fileExists(commentsPath)) {
|
|
7521
|
+
res.status(404).json({ error: "Comments file not found" });
|
|
7522
|
+
return;
|
|
7523
|
+
}
|
|
7524
|
+
const { resolved: desired } = req.body || {};
|
|
7525
|
+
if (typeof desired !== "boolean") {
|
|
7526
|
+
res.status(400).json({ error: "resolved (boolean) is required" });
|
|
7527
|
+
return;
|
|
7528
|
+
}
|
|
7529
|
+
const content = await readFile10(commentsPath, "utf-8");
|
|
7530
|
+
const parsed = parseComments(content);
|
|
7531
|
+
const target = parsed.entries.find((e) => e.id === commentId);
|
|
7532
|
+
if (!target) {
|
|
7533
|
+
res.status(404).json({ error: `Comment ${commentId} not found` });
|
|
7534
|
+
return;
|
|
7535
|
+
}
|
|
7536
|
+
if (target.type !== "question") {
|
|
7537
|
+
res.status(400).json({ error: "Only questions can be resolved" });
|
|
7538
|
+
return;
|
|
7539
|
+
}
|
|
7540
|
+
const entryBlockRegex = new RegExp(
|
|
7541
|
+
`(^## ${commentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?)(\\*\\*Resolved:\\*\\*\\s*(?:true|false))`,
|
|
7542
|
+
"m"
|
|
6296
7543
|
);
|
|
6297
|
-
}
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
const result = isTerminal ? db2.prepare(
|
|
6302
|
-
"UPDATE sessions SET status = ?, ended = datetime('now'), updated_at = datetime('now') WHERE session_id = ?"
|
|
6303
|
-
).run(status, sessionId) : db2.prepare(
|
|
6304
|
-
"UPDATE sessions SET status = ?, updated_at = datetime('now') WHERE session_id = ?"
|
|
6305
|
-
).run(status, sessionId);
|
|
6306
|
-
return result.changes > 0;
|
|
6307
|
-
}
|
|
6308
|
-
async function listAllSessions(_projectsDir) {
|
|
6309
|
-
const db2 = getSessionDb();
|
|
6310
|
-
const rows = db2.prepare("SELECT * FROM sessions ORDER BY started DESC").all();
|
|
6311
|
-
return rows.map(rowToSession);
|
|
6312
|
-
}
|
|
6313
|
-
async function listProjectSessions(_projectsDir, projectSlug, assignmentSlug) {
|
|
6314
|
-
const db2 = getSessionDb();
|
|
6315
|
-
if (assignmentSlug) {
|
|
6316
|
-
const rows2 = db2.prepare(
|
|
6317
|
-
"SELECT * FROM sessions WHERE project_slug = ? AND assignment_slug = ? ORDER BY started DESC"
|
|
6318
|
-
).all(projectSlug, assignmentSlug);
|
|
6319
|
-
return rows2.map(rowToSession);
|
|
7544
|
+
const next = content.replace(entryBlockRegex, (_m, preamble) => `${preamble}**Resolved:** ${desired ? "true" : "false"}`);
|
|
7545
|
+
if (next === content) {
|
|
7546
|
+
res.status(500).json({ error: "Failed to update resolved flag" });
|
|
7547
|
+
return;
|
|
6320
7548
|
}
|
|
6321
|
-
const
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
6325
|
-
if (sessionIds.length === 0) return 0;
|
|
6326
|
-
const db2 = getSessionDb();
|
|
6327
|
-
const placeholders = sessionIds.map(() => "?").join(", ");
|
|
6328
|
-
const result = db2.prepare(`DELETE FROM sessions WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
|
6329
|
-
return result.changes;
|
|
6330
|
-
}
|
|
6331
|
-
var DONE_ASSIGNMENT_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "review"]);
|
|
6332
|
-
async function readAssignmentStatus(projectDir, assignmentSlug) {
|
|
6333
|
-
const assignmentPath = resolve13(projectDir, "assignments", assignmentSlug, "assignment.md");
|
|
6334
|
-
if (!await fileExists(assignmentPath)) return null;
|
|
6335
|
-
const raw = await readFile9(assignmentPath, "utf-8");
|
|
6336
|
-
const match = raw.match(/^status:\s*(.+)$/m);
|
|
6337
|
-
return match ? match[1].trim() : null;
|
|
7549
|
+
const withUpdated = setTopLevelField(next, "updated", `"${nowTimestamp()}"`);
|
|
7550
|
+
await writeFileForce(commentsPath, withUpdated);
|
|
7551
|
+
const assignment = await reloadDetail();
|
|
7552
|
+
res.json({ assignment });
|
|
6338
7553
|
}
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
|
|
6350
|
-
|
|
6351
|
-
|
|
6352
|
-
|
|
6353
|
-
|
|
6354
|
-
|
|
7554
|
+
|
|
7555
|
+
// src/dashboard/api-servers.ts
|
|
7556
|
+
init_servers();
|
|
7557
|
+
init_scanner();
|
|
7558
|
+
import { Router as Router2 } from "express";
|
|
7559
|
+
function createServersRouter(serversDir2, projectsDir2, assignmentsDir2) {
|
|
7560
|
+
const router = Router2();
|
|
7561
|
+
router.get("/", async (_req, res) => {
|
|
7562
|
+
try {
|
|
7563
|
+
const result = await scanAllSessions(serversDir2, projectsDir2, { assignmentsDir: assignmentsDir2 });
|
|
7564
|
+
res.json(result);
|
|
7565
|
+
} catch (error) {
|
|
7566
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Scan failed" });
|
|
7567
|
+
}
|
|
7568
|
+
});
|
|
7569
|
+
router.get("/:name", async (req, res) => {
|
|
7570
|
+
try {
|
|
7571
|
+
const session = await scanSingleSession(serversDir2, projectsDir2, req.params.name, { assignmentsDir: assignmentsDir2 });
|
|
7572
|
+
if (!session) {
|
|
7573
|
+
res.status(404).json({ error: "Session not found" });
|
|
7574
|
+
return;
|
|
7575
|
+
}
|
|
7576
|
+
res.json(session);
|
|
7577
|
+
} catch (error) {
|
|
7578
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Scan failed" });
|
|
7579
|
+
}
|
|
7580
|
+
});
|
|
7581
|
+
router.post("/", async (req, res) => {
|
|
7582
|
+
try {
|
|
7583
|
+
const { name } = req.body;
|
|
7584
|
+
if (!name || typeof name !== "string") {
|
|
7585
|
+
res.status(400).json({ error: "name is required" });
|
|
7586
|
+
return;
|
|
7587
|
+
}
|
|
7588
|
+
const sanitized = sanitizeSessionName(name);
|
|
7589
|
+
const existing = await readSessionFile(serversDir2, sanitized);
|
|
7590
|
+
if (existing) {
|
|
7591
|
+
res.status(409).json({ error: `Session "${sanitized}" already registered` });
|
|
7592
|
+
return;
|
|
7593
|
+
}
|
|
7594
|
+
await registerSession(serversDir2, name);
|
|
7595
|
+
clearScanCache();
|
|
7596
|
+
res.status(201).json({ name: sanitized });
|
|
7597
|
+
} catch (error) {
|
|
7598
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Registration failed" });
|
|
7599
|
+
}
|
|
7600
|
+
});
|
|
7601
|
+
router.delete("/:name", async (req, res) => {
|
|
7602
|
+
try {
|
|
7603
|
+
const data = await readSessionFile(serversDir2, req.params.name);
|
|
7604
|
+
if (!data) {
|
|
7605
|
+
res.status(404).json({ error: "Session not found" });
|
|
7606
|
+
return;
|
|
7607
|
+
}
|
|
7608
|
+
await removeSession(serversDir2, req.params.name);
|
|
7609
|
+
clearScanCache();
|
|
7610
|
+
res.json({ removed: req.params.name });
|
|
7611
|
+
} catch (error) {
|
|
7612
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Removal failed" });
|
|
6355
7613
|
}
|
|
6356
|
-
}
|
|
6357
|
-
|
|
6358
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
6363
|
-
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
7614
|
+
});
|
|
7615
|
+
router.post("/refresh", async (_req, res) => {
|
|
7616
|
+
try {
|
|
7617
|
+
const names = await listSessionFiles(serversDir2);
|
|
7618
|
+
for (const name of names) {
|
|
7619
|
+
await updateLastRefreshed(serversDir2, name);
|
|
7620
|
+
}
|
|
7621
|
+
clearScanCache();
|
|
7622
|
+
const result = await scanAllSessions(serversDir2, projectsDir2, { bypassCache: true, assignmentsDir: assignmentsDir2 });
|
|
7623
|
+
res.json(result);
|
|
7624
|
+
} catch (error) {
|
|
7625
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Refresh failed" });
|
|
7626
|
+
}
|
|
7627
|
+
});
|
|
7628
|
+
router.post("/:name/refresh", async (req, res) => {
|
|
7629
|
+
try {
|
|
7630
|
+
const data = await readSessionFile(serversDir2, req.params.name);
|
|
7631
|
+
if (!data) {
|
|
7632
|
+
res.status(404).json({ error: "Session not found" });
|
|
7633
|
+
return;
|
|
7634
|
+
}
|
|
7635
|
+
await updateLastRefreshed(serversDir2, req.params.name);
|
|
7636
|
+
clearScanCache();
|
|
7637
|
+
const session = await scanSingleSession(serversDir2, projectsDir2, req.params.name, { assignmentsDir: assignmentsDir2 });
|
|
7638
|
+
res.json(session);
|
|
7639
|
+
} catch (error) {
|
|
7640
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Refresh failed" });
|
|
7641
|
+
}
|
|
7642
|
+
});
|
|
7643
|
+
router.patch("/:name/panes/:windowIndex/:paneIndex/assignment", async (req, res) => {
|
|
7644
|
+
try {
|
|
7645
|
+
const { name, windowIndex, paneIndex } = req.params;
|
|
7646
|
+
const data = await readSessionFile(serversDir2, name);
|
|
7647
|
+
if (!data) {
|
|
7648
|
+
res.status(404).json({ error: "Session not found" });
|
|
7649
|
+
return;
|
|
7650
|
+
}
|
|
7651
|
+
const body = req.body;
|
|
7652
|
+
if (body === null || body && body.project && body.assignment) {
|
|
7653
|
+
await setOverride(
|
|
7654
|
+
serversDir2,
|
|
7655
|
+
name,
|
|
7656
|
+
parseInt(windowIndex, 10),
|
|
7657
|
+
parseInt(paneIndex, 10),
|
|
7658
|
+
body
|
|
7659
|
+
);
|
|
7660
|
+
clearScanCache();
|
|
7661
|
+
res.json({ updated: true });
|
|
7662
|
+
} else {
|
|
7663
|
+
res.status(400).json({ error: "Body must be { project, assignment } or null" });
|
|
7664
|
+
}
|
|
7665
|
+
} catch (error) {
|
|
7666
|
+
res.status(500).json({ error: error instanceof Error ? error.message : "Update failed" });
|
|
7667
|
+
}
|
|
7668
|
+
});
|
|
7669
|
+
return router;
|
|
6367
7670
|
}
|
|
6368
7671
|
|
|
6369
7672
|
// src/dashboard/api-agent-sessions.ts
|
|
7673
|
+
import { Router as Router3 } from "express";
|
|
7674
|
+
import { resolve as resolve15 } from "path";
|
|
6370
7675
|
init_fs();
|
|
6371
|
-
function createAgentSessionsRouter(projectsDir2, broadcast) {
|
|
7676
|
+
function createAgentSessionsRouter(projectsDir2, broadcast, assignmentsDir2) {
|
|
6372
7677
|
const router = Router3();
|
|
6373
7678
|
router.get("/", async (_req, res) => {
|
|
6374
7679
|
try {
|
|
6375
|
-
await reconcileActiveSessions(projectsDir2);
|
|
7680
|
+
await reconcileActiveSessions(projectsDir2, assignmentsDir2);
|
|
6376
7681
|
const sessions = await listAllSessions(projectsDir2);
|
|
6377
7682
|
res.json({ sessions, generatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6378
7683
|
} catch (error) {
|
|
@@ -6383,12 +7688,12 @@ function createAgentSessionsRouter(projectsDir2, broadcast) {
|
|
|
6383
7688
|
try {
|
|
6384
7689
|
const { projectSlug } = req.params;
|
|
6385
7690
|
const assignment = req.query.assignment;
|
|
6386
|
-
const projectDir =
|
|
7691
|
+
const projectDir = resolve15(projectsDir2, projectSlug);
|
|
6387
7692
|
if (!await fileExists(projectDir)) {
|
|
6388
7693
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
6389
7694
|
return;
|
|
6390
7695
|
}
|
|
6391
|
-
await reconcileActiveSessions(projectsDir2);
|
|
7696
|
+
await reconcileActiveSessions(projectsDir2, assignmentsDir2);
|
|
6392
7697
|
const sessions = await listProjectSessions(projectsDir2, projectSlug, assignment);
|
|
6393
7698
|
res.json({ sessions, generatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6394
7699
|
} catch (error) {
|
|
@@ -6397,32 +7702,38 @@ function createAgentSessionsRouter(projectsDir2, broadcast) {
|
|
|
6397
7702
|
});
|
|
6398
7703
|
router.post("/", async (req, res) => {
|
|
6399
7704
|
try {
|
|
6400
|
-
const { projectSlug, assignmentSlug, agent, sessionId, path, description } = req.body;
|
|
7705
|
+
const { projectSlug, assignmentSlug, agent, sessionId, path, description, transcriptPath } = req.body;
|
|
6401
7706
|
if (!agent) {
|
|
6402
7707
|
res.status(400).json({ error: "agent is required" });
|
|
6403
7708
|
return;
|
|
6404
7709
|
}
|
|
7710
|
+
if (!sessionId) {
|
|
7711
|
+
res.status(400).json({
|
|
7712
|
+
error: "sessionId is required. Pass the real agent-generated session id \u2014 do not synthesize one."
|
|
7713
|
+
});
|
|
7714
|
+
return;
|
|
7715
|
+
}
|
|
6405
7716
|
if (projectSlug) {
|
|
6406
|
-
const projectDir =
|
|
7717
|
+
const projectDir = resolve15(projectsDir2, projectSlug);
|
|
6407
7718
|
if (!await fileExists(projectDir)) {
|
|
6408
7719
|
res.status(404).json({ error: `Project "${projectSlug}" not found` });
|
|
6409
7720
|
return;
|
|
6410
7721
|
}
|
|
6411
7722
|
}
|
|
6412
|
-
const id = sessionId || randomUUID2();
|
|
6413
7723
|
const session = {
|
|
6414
7724
|
projectSlug: projectSlug || null,
|
|
6415
7725
|
assignmentSlug: assignmentSlug || null,
|
|
6416
7726
|
agent,
|
|
6417
|
-
sessionId
|
|
7727
|
+
sessionId,
|
|
6418
7728
|
started: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6419
7729
|
status: "active",
|
|
6420
7730
|
path: path || "",
|
|
6421
|
-
description: description || null
|
|
7731
|
+
description: description || null,
|
|
7732
|
+
transcriptPath: transcriptPath || null
|
|
6422
7733
|
};
|
|
6423
7734
|
await appendSession("", session);
|
|
6424
7735
|
broadcast?.({ type: "agent-sessions-updated", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
6425
|
-
res.status(201).json({ sessionId
|
|
7736
|
+
res.status(201).json({ sessionId });
|
|
6426
7737
|
} catch (error) {
|
|
6427
7738
|
res.status(500).json({ error: error instanceof Error ? error.message : "Registration failed" });
|
|
6428
7739
|
}
|
|
@@ -6471,8 +7782,8 @@ function createAgentSessionsRouter(projectsDir2, broadcast) {
|
|
|
6471
7782
|
init_api();
|
|
6472
7783
|
init_parser();
|
|
6473
7784
|
import { Router as Router4 } from "express";
|
|
6474
|
-
import { resolve as
|
|
6475
|
-
import { readFile as
|
|
7785
|
+
import { resolve as resolve16 } from "path";
|
|
7786
|
+
import { readFile as readFile11, unlink as unlink2 } from "fs/promises";
|
|
6476
7787
|
init_timestamp();
|
|
6477
7788
|
init_fs();
|
|
6478
7789
|
function createPlaybooksRouter(playbooksDir3) {
|
|
@@ -6512,12 +7823,12 @@ function createPlaybooksRouter(playbooksDir3) {
|
|
|
6512
7823
|
});
|
|
6513
7824
|
router.get("/:slug/edit", async (req, res) => {
|
|
6514
7825
|
try {
|
|
6515
|
-
const filePath =
|
|
7826
|
+
const filePath = resolve16(playbooksDir3, `${req.params.slug}.md`);
|
|
6516
7827
|
if (!await fileExists(filePath)) {
|
|
6517
7828
|
res.status(404).json({ error: `Playbook "${req.params.slug}" not found` });
|
|
6518
7829
|
return;
|
|
6519
7830
|
}
|
|
6520
|
-
const content = await
|
|
7831
|
+
const content = await readFile11(filePath, "utf-8");
|
|
6521
7832
|
res.json({
|
|
6522
7833
|
documentType: "playbook",
|
|
6523
7834
|
title: `Edit Playbook: ${req.params.slug}`,
|
|
@@ -6542,7 +7853,7 @@ function createPlaybooksRouter(playbooksDir3) {
|
|
|
6542
7853
|
return;
|
|
6543
7854
|
}
|
|
6544
7855
|
await ensureDir(playbooksDir3);
|
|
6545
|
-
const filePath =
|
|
7856
|
+
const filePath = resolve16(playbooksDir3, `${slug}.md`);
|
|
6546
7857
|
if (await fileExists(filePath)) {
|
|
6547
7858
|
res.status(409).json({ error: `Playbook "${slug}" already exists` });
|
|
6548
7859
|
return;
|
|
@@ -6561,7 +7872,7 @@ function createPlaybooksRouter(playbooksDir3) {
|
|
|
6561
7872
|
res.status(400).json({ error: "content is required" });
|
|
6562
7873
|
return;
|
|
6563
7874
|
}
|
|
6564
|
-
const filePath =
|
|
7875
|
+
const filePath = resolve16(playbooksDir3, `${req.params.slug}.md`);
|
|
6565
7876
|
if (!await fileExists(filePath)) {
|
|
6566
7877
|
res.status(404).json({ error: `Playbook "${req.params.slug}" not found` });
|
|
6567
7878
|
return;
|
|
@@ -6579,7 +7890,7 @@ function createPlaybooksRouter(playbooksDir3) {
|
|
|
6579
7890
|
res.status(403).json({ error: "The playbook manifest cannot be deleted" });
|
|
6580
7891
|
return;
|
|
6581
7892
|
}
|
|
6582
|
-
const filePath =
|
|
7893
|
+
const filePath = resolve16(playbooksDir3, `${req.params.slug}.md`);
|
|
6583
7894
|
if (!await fileExists(filePath)) {
|
|
6584
7895
|
res.status(404).json({ error: `Playbook "${req.params.slug}" not found` });
|
|
6585
7896
|
return;
|
|
@@ -6598,7 +7909,7 @@ function createPlaybooksRouter(playbooksDir3) {
|
|
|
6598
7909
|
init_parser2();
|
|
6599
7910
|
init_fs();
|
|
6600
7911
|
import { Router as Router5 } from "express";
|
|
6601
|
-
import { readdir as
|
|
7912
|
+
import { readdir as readdir8 } from "fs/promises";
|
|
6602
7913
|
var WORKSPACE_REGEX = /^[a-z0-9_][a-z0-9-]*$/;
|
|
6603
7914
|
function getWorkspaceParam(value) {
|
|
6604
7915
|
if (Array.isArray(value)) {
|
|
@@ -6632,7 +7943,7 @@ function createTodosRouter(todosDir2, broadcast) {
|
|
|
6632
7943
|
router.get("/", async (_req, res) => {
|
|
6633
7944
|
try {
|
|
6634
7945
|
await ensureDir(todosDir2);
|
|
6635
|
-
const files = await
|
|
7946
|
+
const files = await readdir8(todosDir2).catch(() => []);
|
|
6636
7947
|
const workspaces = [];
|
|
6637
7948
|
for (const file of files) {
|
|
6638
7949
|
if (typeof file !== "string") continue;
|
|
@@ -7017,8 +8328,8 @@ init_fs();
|
|
|
7017
8328
|
init_config2();
|
|
7018
8329
|
import { execFile as execFile2 } from "child_process";
|
|
7019
8330
|
import { promisify as promisify2 } from "util";
|
|
7020
|
-
import { cp, mkdtemp, rm as rm2, readFile as
|
|
7021
|
-
import { resolve as
|
|
8331
|
+
import { cp, mkdtemp, rm as rm2, readFile as readFile13, writeFile as writeFile3, unlink as unlink3, stat, open, rename as rename2 } from "fs/promises";
|
|
8332
|
+
import { resolve as resolve18, join as join2 } from "path";
|
|
7022
8333
|
import { tmpdir } from "os";
|
|
7023
8334
|
var exec2 = promisify2(execFile2);
|
|
7024
8335
|
var VALID_CATEGORIES = ["projects", "playbooks", "todos", "servers", "config"];
|
|
@@ -7058,7 +8369,7 @@ async function resolveCategoryPath(category) {
|
|
|
7058
8369
|
case "servers":
|
|
7059
8370
|
return { sourcePath: serversDir(), repoPath: "servers", isFile: false };
|
|
7060
8371
|
case "config":
|
|
7061
|
-
return { sourcePath:
|
|
8372
|
+
return { sourcePath: resolve18(syntaurRoot(), "config.md"), repoPath: "config.md", isFile: true };
|
|
7062
8373
|
}
|
|
7063
8374
|
}
|
|
7064
8375
|
async function checkGitInstalled() {
|
|
@@ -7069,7 +8380,7 @@ async function checkGitInstalled() {
|
|
|
7069
8380
|
}
|
|
7070
8381
|
}
|
|
7071
8382
|
async function acquireLock() {
|
|
7072
|
-
const lockPath =
|
|
8383
|
+
const lockPath = resolve18(syntaurRoot(), LOCK_FILE_NAME);
|
|
7073
8384
|
await ensureDir(syntaurRoot());
|
|
7074
8385
|
try {
|
|
7075
8386
|
const handle = await open(lockPath, "wx");
|
|
@@ -7078,7 +8389,7 @@ async function acquireLock() {
|
|
|
7078
8389
|
return lockPath;
|
|
7079
8390
|
} catch (err2) {
|
|
7080
8391
|
if (err2.code === "EEXIST") {
|
|
7081
|
-
const pid = await
|
|
8392
|
+
const pid = await readFile13(lockPath, "utf-8").catch(() => "");
|
|
7082
8393
|
throw new Error(
|
|
7083
8394
|
`Backup operation already in progress (lock file at ${lockPath}, pid ${pid.trim() || "unknown"}). If stale, delete the file and retry.`
|
|
7084
8395
|
);
|
|
@@ -7116,7 +8427,7 @@ async function copyRecursive(src, dest) {
|
|
|
7116
8427
|
await ensureDir(dest);
|
|
7117
8428
|
await cp(src, dest, { recursive: true, force: true });
|
|
7118
8429
|
} else {
|
|
7119
|
-
await ensureDir(
|
|
8430
|
+
await ensureDir(resolve18(dest, ".."));
|
|
7120
8431
|
await cp(src, dest, { force: true });
|
|
7121
8432
|
}
|
|
7122
8433
|
}
|
|
@@ -7125,7 +8436,7 @@ function resolveCategoriesStrict(csv) {
|
|
|
7125
8436
|
return parseCategoriesStrict(parts);
|
|
7126
8437
|
}
|
|
7127
8438
|
async function readSanitizedConfig(configPath) {
|
|
7128
|
-
const content = await
|
|
8439
|
+
const content = await readFile13(configPath, "utf-8");
|
|
7129
8440
|
return content.replace(/^(\s*lastBackup:\s*).*$/m, "$1null").replace(/^(\s*lastRestore:\s*).*$/m, "$1null");
|
|
7130
8441
|
}
|
|
7131
8442
|
async function backupToGithub(overrides) {
|
|
@@ -7164,7 +8475,7 @@ async function backupToGithub(overrides) {
|
|
|
7164
8475
|
}
|
|
7165
8476
|
if (category === "config") {
|
|
7166
8477
|
const sanitized = await readSanitizedConfig(sourcePath);
|
|
7167
|
-
await ensureDir(
|
|
8478
|
+
await ensureDir(resolve18(destPath, ".."));
|
|
7168
8479
|
await writeFile3(destPath, sanitized, "utf-8");
|
|
7169
8480
|
} else {
|
|
7170
8481
|
await copyRecursive(sourcePath, destPath);
|
|
@@ -7218,7 +8529,7 @@ async function backupToGithub(overrides) {
|
|
|
7218
8529
|
}
|
|
7219
8530
|
async function safeRestoreCategory(localPath, repoSrcPath, isFile) {
|
|
7220
8531
|
if (isFile) {
|
|
7221
|
-
await ensureDir(
|
|
8532
|
+
await ensureDir(resolve18(localPath, ".."));
|
|
7222
8533
|
await cp(repoSrcPath, localPath, { force: true });
|
|
7223
8534
|
return;
|
|
7224
8535
|
}
|
|
@@ -7319,7 +8630,7 @@ async function restoreFromGithub(overrides) {
|
|
|
7319
8630
|
}
|
|
7320
8631
|
async function getBackupStatus() {
|
|
7321
8632
|
const config = await readConfig();
|
|
7322
|
-
const lockPath =
|
|
8633
|
+
const lockPath = resolve18(syntaurRoot(), LOCK_FILE_NAME);
|
|
7323
8634
|
const locked = await fileExists(lockPath);
|
|
7324
8635
|
return {
|
|
7325
8636
|
repo: config.backup?.repo ?? null,
|
|
@@ -7474,7 +8785,7 @@ async function stopAutodiscovery() {
|
|
|
7474
8785
|
function runReconcile() {
|
|
7475
8786
|
if (activeReconcile || !savedOptions) return;
|
|
7476
8787
|
const opts = savedOptions;
|
|
7477
|
-
activeReconcile = reconcile(opts.serversDir, opts.projectsDir, opts.excludePids).catch((err2) => {
|
|
8788
|
+
activeReconcile = reconcile(opts.serversDir, opts.projectsDir, opts.excludePids, opts.assignmentsDir).catch((err2) => {
|
|
7478
8789
|
console.error("[autodiscovery] reconcile failed:", err2);
|
|
7479
8790
|
}).finally(() => {
|
|
7480
8791
|
activeReconcile = null;
|
|
@@ -7485,10 +8796,10 @@ async function listAllTmuxSessions() {
|
|
|
7485
8796
|
if (!output2) return [];
|
|
7486
8797
|
return output2.split("\n").filter((line) => line.length > 0);
|
|
7487
8798
|
}
|
|
7488
|
-
async function discoverTmuxSessions(serversDir2, projectsDir2, existingNames) {
|
|
8799
|
+
async function discoverTmuxSessions(serversDir2, projectsDir2, existingNames, assignmentsDir2) {
|
|
7489
8800
|
const tmuxAvailable = await checkTmuxAvailable();
|
|
7490
8801
|
if (!tmuxAvailable) return false;
|
|
7491
|
-
const workspaceRecords = await loadWorkspaceRecords(projectsDir2);
|
|
8802
|
+
const workspaceRecords = await loadWorkspaceRecords(projectsDir2, assignmentsDir2);
|
|
7492
8803
|
if (workspaceRecords.length === 0) return false;
|
|
7493
8804
|
const sessions = await listAllTmuxSessions();
|
|
7494
8805
|
let changed = false;
|
|
@@ -7529,8 +8840,8 @@ async function getProcessCwd(pid) {
|
|
|
7529
8840
|
}
|
|
7530
8841
|
return null;
|
|
7531
8842
|
}
|
|
7532
|
-
async function discoverProcesses(serversDir2, projectsDir2, existingFiles, excludePids) {
|
|
7533
|
-
const workspaceRecords = await loadWorkspaceRecords(projectsDir2);
|
|
8843
|
+
async function discoverProcesses(serversDir2, projectsDir2, existingFiles, excludePids, assignmentsDir2) {
|
|
8844
|
+
const workspaceRecords = await loadWorkspaceRecords(projectsDir2, assignmentsDir2);
|
|
7534
8845
|
if (workspaceRecords.length === 0) return false;
|
|
7535
8846
|
const lsofOutput = await getLsofOutput();
|
|
7536
8847
|
if (!lsofOutput) return false;
|
|
@@ -7595,7 +8906,7 @@ async function isProcessAlive(pid) {
|
|
|
7595
8906
|
return false;
|
|
7596
8907
|
}
|
|
7597
8908
|
}
|
|
7598
|
-
async function reconcile(serversDir2, projectsDir2, excludePids) {
|
|
8909
|
+
async function reconcile(serversDir2, projectsDir2, excludePids, assignmentsDir2) {
|
|
7599
8910
|
const names = await listSessionFiles(serversDir2);
|
|
7600
8911
|
const existingFiles = /* @__PURE__ */ new Map();
|
|
7601
8912
|
for (const name of names) {
|
|
@@ -7607,8 +8918,8 @@ async function reconcile(serversDir2, projectsDir2, excludePids) {
|
|
|
7607
8918
|
existingFiles.delete(name);
|
|
7608
8919
|
}
|
|
7609
8920
|
const existingNames = new Set(existingFiles.keys());
|
|
7610
|
-
const tmuxChanged = await discoverTmuxSessions(serversDir2, projectsDir2, existingNames);
|
|
7611
|
-
const processChanged = await discoverProcesses(serversDir2, projectsDir2, existingFiles, excludePids);
|
|
8921
|
+
const tmuxChanged = await discoverTmuxSessions(serversDir2, projectsDir2, existingNames, assignmentsDir2);
|
|
8922
|
+
const processChanged = await discoverProcesses(serversDir2, projectsDir2, existingFiles, excludePids, assignmentsDir2);
|
|
7612
8923
|
if (tmuxChanged || processChanged || cleanupChanged) {
|
|
7613
8924
|
clearScanCache();
|
|
7614
8925
|
}
|
|
@@ -7616,7 +8927,7 @@ async function reconcile(serversDir2, projectsDir2, excludePids) {
|
|
|
7616
8927
|
|
|
7617
8928
|
// src/dashboard/server.ts
|
|
7618
8929
|
function createDashboardServer(options) {
|
|
7619
|
-
const { port, projectsDir: projectsDir2, serversDir: serversDir2, playbooksDir: playbooksDir3, todosDir: todosDir2, serveStaticUi, dashboardDistPath } = options;
|
|
8930
|
+
const { port, projectsDir: projectsDir2, assignmentsDir: assignmentsDir2, serversDir: serversDir2, playbooksDir: playbooksDir3, todosDir: todosDir2, serveStaticUi, dashboardDistPath } = options;
|
|
7620
8931
|
const app = express();
|
|
7621
8932
|
const server = createServer(app);
|
|
7622
8933
|
const wss = new WebSocketServer({ noServer: true });
|
|
@@ -7656,7 +8967,7 @@ function createDashboardServer(options) {
|
|
|
7656
8967
|
app.use(express.json());
|
|
7657
8968
|
app.get("/api/overview", async (_req, res) => {
|
|
7658
8969
|
try {
|
|
7659
|
-
const overview = await getOverview(projectsDir2, serversDir2);
|
|
8970
|
+
const overview = await getOverview(projectsDir2, serversDir2, assignmentsDir2);
|
|
7660
8971
|
res.json(overview);
|
|
7661
8972
|
} catch (error) {
|
|
7662
8973
|
console.error("Error getting overview:", error);
|
|
@@ -7665,7 +8976,7 @@ function createDashboardServer(options) {
|
|
|
7665
8976
|
});
|
|
7666
8977
|
app.get("/api/attention", async (_req, res) => {
|
|
7667
8978
|
try {
|
|
7668
|
-
const attention = await getAttention(projectsDir2, serversDir2);
|
|
8979
|
+
const attention = await getAttention(projectsDir2, serversDir2, assignmentsDir2);
|
|
7669
8980
|
res.json(attention);
|
|
7670
8981
|
} catch (error) {
|
|
7671
8982
|
console.error("Error getting attention queue:", error);
|
|
@@ -7785,7 +9096,7 @@ function createDashboardServer(options) {
|
|
|
7785
9096
|
});
|
|
7786
9097
|
app.get("/api/assignments", async (req, res) => {
|
|
7787
9098
|
try {
|
|
7788
|
-
const result = await listAssignmentsBoard(projectsDir2);
|
|
9099
|
+
const result = await listAssignmentsBoard(projectsDir2, assignmentsDir2);
|
|
7789
9100
|
const workspaceParam = req.query.workspace;
|
|
7790
9101
|
if (workspaceParam) {
|
|
7791
9102
|
if (workspaceParam === "_ungrouped") {
|
|
@@ -7813,6 +9124,37 @@ function createDashboardServer(options) {
|
|
|
7813
9124
|
res.status(500).json({ error: "Failed to get project detail" });
|
|
7814
9125
|
}
|
|
7815
9126
|
});
|
|
9127
|
+
app.get("/api/assignments/:id", async (req, res) => {
|
|
9128
|
+
try {
|
|
9129
|
+
const detail = await getAssignmentDetailById(projectsDir2, assignmentsDir2, req.params.id);
|
|
9130
|
+
if (!detail) {
|
|
9131
|
+
res.status(404).json({ error: `Assignment "${req.params.id}" not found` });
|
|
9132
|
+
return;
|
|
9133
|
+
}
|
|
9134
|
+
res.json(detail);
|
|
9135
|
+
} catch (error) {
|
|
9136
|
+
console.error("Error getting assignment by id:", error);
|
|
9137
|
+
res.status(500).json({ error: "Failed to get assignment" });
|
|
9138
|
+
}
|
|
9139
|
+
});
|
|
9140
|
+
app.get("/api/assignments/:id/sessions", async (req, res) => {
|
|
9141
|
+
try {
|
|
9142
|
+
const resolved = await resolveAssignmentById(projectsDir2, assignmentsDir2, req.params.id);
|
|
9143
|
+
if (!resolved) {
|
|
9144
|
+
res.status(404).json({ error: `Assignment "${req.params.id}" not found` });
|
|
9145
|
+
return;
|
|
9146
|
+
}
|
|
9147
|
+
await reconcileActiveSessions(projectsDir2, assignmentsDir2);
|
|
9148
|
+
const sessions = await listSessionsByAssignment(
|
|
9149
|
+
resolved.standalone ? null : resolved.projectSlug,
|
|
9150
|
+
resolved.standalone ? resolved.id : resolved.assignmentSlug
|
|
9151
|
+
);
|
|
9152
|
+
res.json({ sessions, generatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
9153
|
+
} catch (error) {
|
|
9154
|
+
console.error("Error listing sessions by id:", error);
|
|
9155
|
+
res.status(500).json({ error: "Failed to list sessions" });
|
|
9156
|
+
}
|
|
9157
|
+
});
|
|
7816
9158
|
app.get("/api/projects/:slug/assignments/:aslug", async (req, res) => {
|
|
7817
9159
|
try {
|
|
7818
9160
|
const detail = await getAssignmentDetail(
|
|
@@ -7832,16 +9174,16 @@ function createDashboardServer(options) {
|
|
|
7832
9174
|
res.status(500).json({ error: "Failed to get assignment detail" });
|
|
7833
9175
|
}
|
|
7834
9176
|
});
|
|
7835
|
-
app.use(createWriteRouter(projectsDir2));
|
|
7836
|
-
app.use("/api/servers", createServersRouter(serversDir2, projectsDir2));
|
|
7837
|
-
app.use("/api/agent-sessions", createAgentSessionsRouter(projectsDir2, broadcast));
|
|
9177
|
+
app.use(createWriteRouter(projectsDir2, assignmentsDir2));
|
|
9178
|
+
app.use("/api/servers", createServersRouter(serversDir2, projectsDir2, assignmentsDir2));
|
|
9179
|
+
app.use("/api/agent-sessions", createAgentSessionsRouter(projectsDir2, broadcast, assignmentsDir2));
|
|
7838
9180
|
app.use("/api/playbooks", createPlaybooksRouter(playbooksDir3));
|
|
7839
9181
|
app.use("/api/todos", createTodosRouter(todosDir2, broadcast));
|
|
7840
9182
|
app.use("/api/backup", createBackupRouter());
|
|
7841
9183
|
if (serveStaticUi && dashboardDistPath) {
|
|
7842
9184
|
app.use(express.static(dashboardDistPath));
|
|
7843
9185
|
app.get("{*path}", async (_req, res) => {
|
|
7844
|
-
const indexPath =
|
|
9186
|
+
const indexPath = resolve19(dashboardDistPath, "index.html");
|
|
7845
9187
|
if (await fileExists(indexPath)) {
|
|
7846
9188
|
res.sendFile(indexPath);
|
|
7847
9189
|
} else {
|
|
@@ -7856,12 +9198,13 @@ function createDashboardServer(options) {
|
|
|
7856
9198
|
async start() {
|
|
7857
9199
|
watcherHandle = createWatcher({
|
|
7858
9200
|
projectsDir: projectsDir2,
|
|
9201
|
+
assignmentsDir: assignmentsDir2,
|
|
7859
9202
|
serversDir: serversDir2,
|
|
7860
9203
|
playbooksDir: playbooksDir3,
|
|
7861
9204
|
todosDir: todosDir2,
|
|
7862
9205
|
onMessage: broadcast
|
|
7863
9206
|
});
|
|
7864
|
-
startAutodiscovery({ serversDir: serversDir2, projectsDir: projectsDir2, excludePids: /* @__PURE__ */ new Set([process.pid]) });
|
|
9207
|
+
startAutodiscovery({ serversDir: serversDir2, projectsDir: projectsDir2, assignmentsDir: assignmentsDir2, excludePids: /* @__PURE__ */ new Set([process.pid]) });
|
|
7865
9208
|
return new Promise((resolvePromise, reject) => {
|
|
7866
9209
|
server.on("error", (err2) => {
|
|
7867
9210
|
if (err2.code === "EADDRINUSE") {
|
|
@@ -7873,7 +9216,7 @@ function createDashboardServer(options) {
|
|
|
7873
9216
|
}
|
|
7874
9217
|
});
|
|
7875
9218
|
server.listen(port, () => {
|
|
7876
|
-
const portFile =
|
|
9219
|
+
const portFile = resolve19(syntaurRoot(), "dashboard-port");
|
|
7877
9220
|
writeFile4(portFile, String(port), "utf-8").catch(() => {
|
|
7878
9221
|
});
|
|
7879
9222
|
resolvePromise();
|
|
@@ -7890,7 +9233,7 @@ function createDashboardServer(options) {
|
|
|
7890
9233
|
client.terminate();
|
|
7891
9234
|
}
|
|
7892
9235
|
clients.clear();
|
|
7893
|
-
const portFile =
|
|
9236
|
+
const portFile = resolve19(syntaurRoot(), "dashboard-port");
|
|
7894
9237
|
await unlink4(portFile).catch(() => {
|
|
7895
9238
|
});
|
|
7896
9239
|
server.closeAllConnections?.();
|
|
@@ -7970,11 +9313,12 @@ async function dashboardCommand(options) {
|
|
|
7970
9313
|
port = availablePort;
|
|
7971
9314
|
}
|
|
7972
9315
|
const thisFile = fileURLToPath2(import.meta.url);
|
|
7973
|
-
const packageRoot =
|
|
7974
|
-
const dashboardDist =
|
|
9316
|
+
const packageRoot = resolve20(dirname4(thisFile), "..");
|
|
9317
|
+
const dashboardDist = resolve20(packageRoot, "dashboard", "dist");
|
|
7975
9318
|
const server = createDashboardServer({
|
|
7976
9319
|
port,
|
|
7977
9320
|
projectsDir: projectsDir2,
|
|
9321
|
+
assignmentsDir: assignmentsDir(),
|
|
7978
9322
|
serversDir: serversDir(),
|
|
7979
9323
|
playbooksDir: playbooksDir(),
|
|
7980
9324
|
todosDir: todosDir(),
|
|
@@ -7984,8 +9328,8 @@ async function dashboardCommand(options) {
|
|
|
7984
9328
|
await server.start();
|
|
7985
9329
|
let viteProcess = null;
|
|
7986
9330
|
if (mode === "dev") {
|
|
7987
|
-
const dashboardDir =
|
|
7988
|
-
const viteBin =
|
|
9331
|
+
const dashboardDir = resolve20(packageRoot, "dashboard");
|
|
9332
|
+
const viteBin = resolve20(dashboardDir, "node_modules", ".bin", "vite");
|
|
7989
9333
|
if (!await fileExists(viteBin)) {
|
|
7990
9334
|
console.error(
|
|
7991
9335
|
'Vite not found. Run "npm ci --prefix dashboard" first, or use the default bundled dashboard mode.'
|
|
@@ -8059,71 +9403,7 @@ init_fs();
|
|
|
8059
9403
|
init_config2();
|
|
8060
9404
|
import { resolve as resolve21 } from "path";
|
|
8061
9405
|
init_lifecycle();
|
|
8062
|
-
|
|
8063
|
-
// src/utils/assignment-resolver.ts
|
|
8064
|
-
init_fs();
|
|
8065
|
-
init_parser();
|
|
8066
|
-
import { resolve as resolve20 } from "path";
|
|
8067
|
-
import { readdir as readdir8, readFile as readFile13 } from "fs/promises";
|
|
8068
|
-
async function resolveAssignmentById(projectsDir2, assignmentsDir2, id) {
|
|
8069
|
-
let standaloneMatch = null;
|
|
8070
|
-
let projectMatch = null;
|
|
8071
|
-
const standaloneDir = resolve20(assignmentsDir2, id);
|
|
8072
|
-
const standalonePath = resolve20(standaloneDir, "assignment.md");
|
|
8073
|
-
if (await fileExists(standalonePath)) {
|
|
8074
|
-
standaloneMatch = {
|
|
8075
|
-
assignmentDir: standaloneDir,
|
|
8076
|
-
projectSlug: null,
|
|
8077
|
-
assignmentSlug: id,
|
|
8078
|
-
id,
|
|
8079
|
-
standalone: true
|
|
8080
|
-
};
|
|
8081
|
-
}
|
|
8082
|
-
if (await fileExists(projectsDir2)) {
|
|
8083
|
-
try {
|
|
8084
|
-
const projects = await readdir8(projectsDir2, { withFileTypes: true });
|
|
8085
|
-
for (const p of projects) {
|
|
8086
|
-
if (!p.isDirectory()) continue;
|
|
8087
|
-
if (p.name.startsWith(".") || p.name.startsWith("_")) continue;
|
|
8088
|
-
const assignmentsPath = resolve20(projectsDir2, p.name, "assignments");
|
|
8089
|
-
if (!await fileExists(assignmentsPath)) continue;
|
|
8090
|
-
const entries = await readdir8(assignmentsPath, { withFileTypes: true });
|
|
8091
|
-
for (const a of entries) {
|
|
8092
|
-
if (!a.isDirectory()) continue;
|
|
8093
|
-
const aPath = resolve20(assignmentsPath, a.name, "assignment.md");
|
|
8094
|
-
if (!await fileExists(aPath)) continue;
|
|
8095
|
-
try {
|
|
8096
|
-
const content = await readFile13(aPath, "utf-8");
|
|
8097
|
-
const [fm] = extractFrontmatter(content);
|
|
8098
|
-
const fileId = getField(fm, "id");
|
|
8099
|
-
if (fileId === id) {
|
|
8100
|
-
projectMatch = {
|
|
8101
|
-
assignmentDir: resolve20(assignmentsPath, a.name),
|
|
8102
|
-
projectSlug: p.name,
|
|
8103
|
-
assignmentSlug: a.name,
|
|
8104
|
-
id,
|
|
8105
|
-
standalone: false
|
|
8106
|
-
};
|
|
8107
|
-
break;
|
|
8108
|
-
}
|
|
8109
|
-
} catch {
|
|
8110
|
-
}
|
|
8111
|
-
}
|
|
8112
|
-
if (projectMatch) break;
|
|
8113
|
-
}
|
|
8114
|
-
} catch {
|
|
8115
|
-
}
|
|
8116
|
-
}
|
|
8117
|
-
if (standaloneMatch && projectMatch) {
|
|
8118
|
-
console.warn(
|
|
8119
|
-
`Duplicate assignment ID ${id} found in both standalone and project-nested locations; using standalone`
|
|
8120
|
-
);
|
|
8121
|
-
return standaloneMatch;
|
|
8122
|
-
}
|
|
8123
|
-
return standaloneMatch ?? projectMatch ?? null;
|
|
8124
|
-
}
|
|
8125
|
-
|
|
8126
|
-
// src/commands/_lifecycle-helper.ts
|
|
9406
|
+
init_assignment_resolver();
|
|
8127
9407
|
async function runTransition(assignment, command, options = {}) {
|
|
8128
9408
|
const config = await readConfig();
|
|
8129
9409
|
const baseDir = options.dir ? expandHome(options.dir) : config.defaultProjectDir;
|
|
@@ -9501,7 +10781,7 @@ init_paths();
|
|
|
9501
10781
|
init_fs();
|
|
9502
10782
|
init_config2();
|
|
9503
10783
|
import { resolve as resolve26 } from "path";
|
|
9504
|
-
import { randomUUID as
|
|
10784
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
9505
10785
|
async function trackSessionCommand(options) {
|
|
9506
10786
|
if (!options.agent) {
|
|
9507
10787
|
throw new Error("--agent <name> is required.");
|
|
@@ -9517,7 +10797,7 @@ async function trackSessionCommand(options) {
|
|
|
9517
10797
|
}
|
|
9518
10798
|
}
|
|
9519
10799
|
initSessionDb();
|
|
9520
|
-
const sessionId = options.sessionId ||
|
|
10800
|
+
const sessionId = options.sessionId || randomUUID2();
|
|
9521
10801
|
await appendSession("", {
|
|
9522
10802
|
projectSlug: options.project || null,
|
|
9523
10803
|
assignmentSlug: options.assignment || null,
|
|
@@ -10895,6 +12175,7 @@ function pass3(check) {
|
|
|
10895
12175
|
init_fs();
|
|
10896
12176
|
init_parser();
|
|
10897
12177
|
init_types();
|
|
12178
|
+
init_paths();
|
|
10898
12179
|
import { resolve as resolve35 } from "path";
|
|
10899
12180
|
import { readdir as readdir13, readFile as readFile18 } from "fs/promises";
|
|
10900
12181
|
var CATEGORY4 = "assignment";
|
|
@@ -10902,24 +12183,48 @@ var STATUSES_REQUIRING_HANDOFF = /* @__PURE__ */ new Set(["review", "completed"]
|
|
|
10902
12183
|
async function listAssignments(ctx) {
|
|
10903
12184
|
const result = { withAssignmentMd: [], orphanFolders: [] };
|
|
10904
12185
|
const projectsDir2 = ctx.config.defaultProjectDir;
|
|
10905
|
-
if (
|
|
10906
|
-
|
|
10907
|
-
|
|
10908
|
-
|
|
10909
|
-
|
|
10910
|
-
|
|
10911
|
-
|
|
10912
|
-
|
|
12186
|
+
if (await fileExists(projectsDir2)) {
|
|
12187
|
+
const projects = await readdir13(projectsDir2, { withFileTypes: true });
|
|
12188
|
+
for (const m of projects) {
|
|
12189
|
+
if (!m.isDirectory()) continue;
|
|
12190
|
+
if (m.name.startsWith(".") || m.name.startsWith("_")) continue;
|
|
12191
|
+
const assignmentsDir2 = resolve35(projectsDir2, m.name, "assignments");
|
|
12192
|
+
if (!await fileExists(assignmentsDir2)) continue;
|
|
12193
|
+
const entries = await readdir13(assignmentsDir2, { withFileTypes: true });
|
|
12194
|
+
for (const a of entries) {
|
|
12195
|
+
if (!a.isDirectory()) continue;
|
|
12196
|
+
if (a.name.startsWith(".") || a.name.startsWith("_")) continue;
|
|
12197
|
+
const assignmentDir = resolve35(assignmentsDir2, a.name);
|
|
12198
|
+
const assignmentMd = resolve35(assignmentDir, "assignment.md");
|
|
12199
|
+
const entry = {
|
|
12200
|
+
projectDir: resolve35(projectsDir2, m.name),
|
|
12201
|
+
projectSlug: m.name,
|
|
12202
|
+
assignmentDir,
|
|
12203
|
+
assignmentSlug: a.name,
|
|
12204
|
+
standalone: false
|
|
12205
|
+
};
|
|
12206
|
+
if (await fileExists(assignmentMd)) {
|
|
12207
|
+
result.withAssignmentMd.push(entry);
|
|
12208
|
+
} else {
|
|
12209
|
+
result.orphanFolders.push(entry);
|
|
12210
|
+
}
|
|
12211
|
+
}
|
|
12212
|
+
}
|
|
12213
|
+
}
|
|
12214
|
+
const standaloneRoot = assignmentsDir();
|
|
12215
|
+
if (await fileExists(standaloneRoot)) {
|
|
12216
|
+
const entries = await readdir13(standaloneRoot, { withFileTypes: true });
|
|
10913
12217
|
for (const a of entries) {
|
|
10914
12218
|
if (!a.isDirectory()) continue;
|
|
10915
12219
|
if (a.name.startsWith(".") || a.name.startsWith("_")) continue;
|
|
10916
|
-
const assignmentDir = resolve35(
|
|
12220
|
+
const assignmentDir = resolve35(standaloneRoot, a.name);
|
|
10917
12221
|
const assignmentMd = resolve35(assignmentDir, "assignment.md");
|
|
10918
12222
|
const entry = {
|
|
10919
|
-
projectDir:
|
|
10920
|
-
projectSlug:
|
|
12223
|
+
projectDir: standaloneRoot,
|
|
12224
|
+
projectSlug: null,
|
|
10921
12225
|
assignmentDir,
|
|
10922
|
-
assignmentSlug: a.name
|
|
12226
|
+
assignmentSlug: a.name,
|
|
12227
|
+
standalone: true
|
|
10923
12228
|
};
|
|
10924
12229
|
if (await fileExists(assignmentMd)) {
|
|
10925
12230
|
result.withAssignmentMd.push(entry);
|
|
@@ -11099,12 +12404,147 @@ var requiredFilesByStatus = {
|
|
|
11099
12404
|
return results;
|
|
11100
12405
|
}
|
|
11101
12406
|
};
|
|
12407
|
+
var companionFilesScaffolded = {
|
|
12408
|
+
id: "assignment.companion-files",
|
|
12409
|
+
category: CATEGORY4,
|
|
12410
|
+
title: "progress.md and comments.md scaffolded (v2.0)",
|
|
12411
|
+
async run(ctx) {
|
|
12412
|
+
const { withAssignmentMd } = await listAssignments(ctx);
|
|
12413
|
+
const results = [];
|
|
12414
|
+
for (const a of withAssignmentMd) {
|
|
12415
|
+
const missing = [];
|
|
12416
|
+
for (const filename of ["progress.md", "comments.md"]) {
|
|
12417
|
+
if (!await fileExists(resolve35(a.assignmentDir, filename))) {
|
|
12418
|
+
missing.push(filename);
|
|
12419
|
+
}
|
|
12420
|
+
}
|
|
12421
|
+
if (missing.length === 0) continue;
|
|
12422
|
+
const label = a.standalone ? `standalone/${a.assignmentSlug}` : `${a.projectSlug}/${a.assignmentSlug}`;
|
|
12423
|
+
results.push({
|
|
12424
|
+
id: this.id,
|
|
12425
|
+
category: this.category,
|
|
12426
|
+
title: this.title,
|
|
12427
|
+
status: "warn",
|
|
12428
|
+
detail: `${label} is missing ${missing.join(" and ")} (pre-v2.0 assignment \u2014 not required, but scaffolding them keeps the dashboard and CLIs consistent)`,
|
|
12429
|
+
affected: missing.map((m) => resolve35(a.assignmentDir, m)),
|
|
12430
|
+
remediation: {
|
|
12431
|
+
kind: "manual",
|
|
12432
|
+
suggestion: `Create ${missing.join(" and ")} with the renderProgress/renderComments templates, or re-scaffold via the CLI`,
|
|
12433
|
+
command: null
|
|
12434
|
+
},
|
|
12435
|
+
autoFixable: false
|
|
12436
|
+
});
|
|
12437
|
+
}
|
|
12438
|
+
if (results.length === 0) return pass4(this);
|
|
12439
|
+
return results;
|
|
12440
|
+
}
|
|
12441
|
+
};
|
|
12442
|
+
var typeDefinition = {
|
|
12443
|
+
id: "assignment.type-definition",
|
|
12444
|
+
category: CATEGORY4,
|
|
12445
|
+
title: "Assignment `type` is in config.types.definitions",
|
|
12446
|
+
async run(ctx) {
|
|
12447
|
+
const typesConfig = ctx.config.types;
|
|
12448
|
+
if (!typesConfig) {
|
|
12449
|
+
return {
|
|
12450
|
+
id: this.id,
|
|
12451
|
+
category: this.category,
|
|
12452
|
+
title: this.title,
|
|
12453
|
+
status: "skipped",
|
|
12454
|
+
detail: "config.types is not set; applying defaults \u2014 skipping strict validation",
|
|
12455
|
+
autoFixable: false
|
|
12456
|
+
};
|
|
12457
|
+
}
|
|
12458
|
+
const allowed = new Set(typesConfig.definitions.map((d) => d.id));
|
|
12459
|
+
const { withAssignmentMd } = await listAssignments(ctx);
|
|
12460
|
+
const results = [];
|
|
12461
|
+
for (const a of withAssignmentMd) {
|
|
12462
|
+
const path = resolve35(a.assignmentDir, "assignment.md");
|
|
12463
|
+
const parsed = await parseSafe(path);
|
|
12464
|
+
if (!parsed) continue;
|
|
12465
|
+
if (!parsed.type) continue;
|
|
12466
|
+
if (!allowed.has(parsed.type)) {
|
|
12467
|
+
const label = a.standalone ? `standalone/${a.assignmentSlug}` : `${a.projectSlug}/${a.assignmentSlug}`;
|
|
12468
|
+
results.push({
|
|
12469
|
+
id: this.id,
|
|
12470
|
+
category: this.category,
|
|
12471
|
+
title: this.title,
|
|
12472
|
+
status: "warn",
|
|
12473
|
+
detail: `${label}: type "${parsed.type}" is not in config.types.definitions (${[...allowed].join(", ")})`,
|
|
12474
|
+
affected: [path],
|
|
12475
|
+
remediation: {
|
|
12476
|
+
kind: "manual",
|
|
12477
|
+
suggestion: `Either add "${parsed.type}" to config.types.definitions or change the assignment's type to one of the configured values`,
|
|
12478
|
+
command: null
|
|
12479
|
+
},
|
|
12480
|
+
autoFixable: false
|
|
12481
|
+
});
|
|
12482
|
+
}
|
|
12483
|
+
}
|
|
12484
|
+
if (results.length === 0) return pass4(this);
|
|
12485
|
+
return results;
|
|
12486
|
+
}
|
|
12487
|
+
};
|
|
12488
|
+
var projectFrontmatterMatchesContainer = {
|
|
12489
|
+
id: "assignment.project-matches-container",
|
|
12490
|
+
category: CATEGORY4,
|
|
12491
|
+
title: "`project` frontmatter matches containing project slug (or null for standalone)",
|
|
12492
|
+
async run(ctx) {
|
|
12493
|
+
const { withAssignmentMd } = await listAssignments(ctx);
|
|
12494
|
+
const results = [];
|
|
12495
|
+
for (const a of withAssignmentMd) {
|
|
12496
|
+
const path = resolve35(a.assignmentDir, "assignment.md");
|
|
12497
|
+
const parsed = await parseSafe(path);
|
|
12498
|
+
if (!parsed) continue;
|
|
12499
|
+
if (a.standalone) {
|
|
12500
|
+
if (parsed.project !== null) {
|
|
12501
|
+
results.push({
|
|
12502
|
+
id: this.id,
|
|
12503
|
+
category: this.category,
|
|
12504
|
+
title: this.title,
|
|
12505
|
+
status: "error",
|
|
12506
|
+
detail: `standalone/${a.assignmentSlug}: frontmatter declares project "${parsed.project}" but the folder is under ~/.syntaur/assignments/ (project must be null)`,
|
|
12507
|
+
affected: [path],
|
|
12508
|
+
remediation: {
|
|
12509
|
+
kind: "manual",
|
|
12510
|
+
suggestion: "Set `project: null` in the frontmatter, or move the folder into a project.",
|
|
12511
|
+
command: null
|
|
12512
|
+
},
|
|
12513
|
+
autoFixable: false
|
|
12514
|
+
});
|
|
12515
|
+
}
|
|
12516
|
+
} else {
|
|
12517
|
+
if (parsed.project !== a.projectSlug) {
|
|
12518
|
+
results.push({
|
|
12519
|
+
id: this.id,
|
|
12520
|
+
category: this.category,
|
|
12521
|
+
title: this.title,
|
|
12522
|
+
status: "error",
|
|
12523
|
+
detail: `${a.projectSlug}/${a.assignmentSlug}: frontmatter declares project "${parsed.project ?? "null"}" but the folder is inside project "${a.projectSlug}"`,
|
|
12524
|
+
affected: [path],
|
|
12525
|
+
remediation: {
|
|
12526
|
+
kind: "manual",
|
|
12527
|
+
suggestion: `Set \`project: ${a.projectSlug}\` in the frontmatter.`,
|
|
12528
|
+
command: null
|
|
12529
|
+
},
|
|
12530
|
+
autoFixable: false
|
|
12531
|
+
});
|
|
12532
|
+
}
|
|
12533
|
+
}
|
|
12534
|
+
}
|
|
12535
|
+
if (results.length === 0) return pass4(this);
|
|
12536
|
+
return results;
|
|
12537
|
+
}
|
|
12538
|
+
};
|
|
11102
12539
|
var assignmentChecks = [
|
|
11103
12540
|
requiredFiles2,
|
|
11104
12541
|
orphanedFolder,
|
|
11105
12542
|
invalidStatus,
|
|
11106
12543
|
workspaceMissing,
|
|
11107
|
-
requiredFilesByStatus
|
|
12544
|
+
requiredFilesByStatus,
|
|
12545
|
+
companionFilesScaffolded,
|
|
12546
|
+
typeDefinition,
|
|
12547
|
+
projectFrontmatterMatchesContainer
|
|
11108
12548
|
];
|
|
11109
12549
|
async function parseSafe(path) {
|
|
11110
12550
|
try {
|
|
@@ -11785,6 +13225,7 @@ init_config2();
|
|
|
11785
13225
|
import { resolve as resolve38 } from "path";
|
|
11786
13226
|
import { readFile as readFile21 } from "fs/promises";
|
|
11787
13227
|
init_timestamp();
|
|
13228
|
+
init_assignment_resolver();
|
|
11788
13229
|
function shortId() {
|
|
11789
13230
|
return generateId().split("-")[0];
|
|
11790
13231
|
}
|
|
@@ -11870,6 +13311,7 @@ init_config2();
|
|
|
11870
13311
|
import { resolve as resolve39 } from "path";
|
|
11871
13312
|
import { readFile as readFile22 } from "fs/promises";
|
|
11872
13313
|
init_timestamp();
|
|
13314
|
+
init_assignment_resolver();
|
|
11873
13315
|
function setTopLevelField3(content, key, value) {
|
|
11874
13316
|
const fieldRegex = new RegExp(`^(${key}:)\\s*.*$`, "m");
|
|
11875
13317
|
if (fieldRegex.test(content)) {
|
|
@@ -11912,7 +13354,7 @@ async function requestCommand(target, text, options = {}) {
|
|
|
11912
13354
|
const todosHeading = /^## Todos\s*$/m;
|
|
11913
13355
|
if (todosHeading.test(content)) {
|
|
11914
13356
|
content = content.replace(
|
|
11915
|
-
/(^## Todos[\s\S]*?)(\n
|
|
13357
|
+
/(^## Todos[\s\S]*?)(\n## |\n*$)/m,
|
|
11916
13358
|
(_m, section, nextHeading) => {
|
|
11917
13359
|
return `${section.trimEnd()}
|
|
11918
13360
|
${todoLine}
|