From fc601f4d291306ae06acd8f30fc97c6b398cf05a Mon Sep 17 00:00:00 2001 From: lyon Date: Thu, 25 Jun 2026 21:38:08 +0800 Subject: [PATCH] feat: standardize mdtodo rxx parser Refs #2157 --- internal/project-management/mdtodo.test.ts | 95 ++++- internal/project-management/mdtodo.ts | 443 +++++++++++++++++++-- internal/project-management/server.test.ts | 3 +- 3 files changed, 513 insertions(+), 28 deletions(-) diff --git a/internal/project-management/mdtodo.test.ts b/internal/project-management/mdtodo.test.ts index e11d9db8..b9e09158 100644 --- a/internal/project-management/mdtodo.test.ts +++ b/internal/project-management/mdtodo.test.ts @@ -1,13 +1,51 @@ import { expect, test } from "bun:test"; -import { parseMdtodoDocument } from "./mdtodo.ts"; +import { mutateMdtodoDocument, parseMdtodoDocument } from "./mdtodo.ts"; -test("parseMdtodoDocument projects markdown tasks without raw body leakage", () => { - const document = parseMdtodoDocument(`# Demo\n\n- [ ] Root task\n - [x] Child done\n- [-] Blocked item\n`, { +test("parseMdtodoDocument projects Rxx hierarchy without raw body leakage", () => { + const document = parseMdtodoDocument(`# Demo + +## R1 Root task [Processing] + +Root notes with [context](https://example.test/context). + +### R1.1 Child done [Finished] + +- implementation notes stay in source markdown + +## R2 Backlog item +`, { sourceId: "demo", relativePath: "project-management/MDTODO.md" }); + expect(document.parserMode).toBe("rxx"); + expect(document.taskCount).toBe(3); + expect(document.tasks.map((task) => task.taskId)).toEqual(["R1", "R1.1", "R2"]); + expect(document.tasks.map((task) => task.status)).toEqual(["in_progress", "done", "open"]); + expect(document.tasks[1].parentTaskRef).toBe(document.tasks[0].taskRef); + expect(document.tasks[0].depth).toBe(0); + expect(document.tasks[1].depth).toBe(1); + expect(document.tasks[0].linkCount).toBe(1); + expect(document.tasks[0].bodyPreview).toBe("Root notes with [context](https://example.test/context)."); + expect(document.tasks[0].body).toBeUndefined(); + expect(document.tasks[0].rawContent).toBeUndefined(); + expect(document.document.parserMode).toBe("rxx"); + expect(document.document.valuesRedacted).toBe(true); +}); + +test("parseMdtodoDocument keeps legacy checkbox parsing explicit", () => { + const document = parseMdtodoDocument(`# Demo + +- [ ] Root task + - [x] Child done +- [-] Blocked item +`, { + sourceId: "demo", + relativePath: "project-management/MDTODO.md" + }); + + expect(document.parserMode).toBe("legacy-checkbox"); expect(document.taskCount).toBe(3); expect(document.tasks[0].status).toBe("open"); expect(document.tasks[1].status).toBe("done"); @@ -15,3 +53,54 @@ test("parseMdtodoDocument projects markdown tasks without raw body leakage", () expect(document.tasks[2].status).toBe("blocked"); expect(document.document.valuesRedacted).toBe(true); }); + +test("mutateMdtodoDocument supports Rxx add update delete operations", () => { + const initial = `# Demo + +## R1 Root task + +Original body. + +### R1.1 Existing child +`; + + let result = mutateMdtodoDocument(initial, { type: "addTask", title: "Second root" }); + expect(result.ok).toBe(true); + expect(result.changedTaskId).toBe("R2"); + expect(result.markdown).toContain("## R2 Second root"); + + result = mutateMdtodoDocument(result.markdown, { type: "addTask", parentRxxId: "R1", title: "Second child", body: "Child body" }); + expect(result.changedTaskId).toBe("R1.2"); + expect(result.markdown).toContain("### R1.2 Second child\n\nChild body"); + + result = mutateMdtodoDocument(result.markdown, { type: "addTask", afterRxxId: "R1.1", title: "Continue child" }); + expect(result.changedTaskId).toBe("R1.3"); + expect(result.markdown).toContain("### R1.3 Continue child"); + + result = mutateMdtodoDocument(result.markdown, { type: "updateStatus", rxxId: "R1", status: "done" }); + expect(result.markdown).toContain("## R1 Root task [completed]"); + + result = mutateMdtodoDocument(result.markdown, { type: "updateTitle", rxxId: "R1", title: "Renamed root" }); + expect(result.markdown).toContain("## R1 Renamed root [completed]"); + + result = mutateMdtodoDocument(result.markdown, { type: "updateBody", rxxId: "R1", body: "Rewritten body." }); + expect(result.markdown).toContain("## R1 Renamed root [completed]\n\nRewritten body.\n\n### R1.1 Existing child"); + + result = mutateMdtodoDocument(result.markdown, { type: "deleteTask", rxxId: "R1.2" }); + expect(result.affectedTaskIds).toEqual(["R1.2"]); + expect(result.markdown).not.toContain("R1.2"); + expect(result.document.tasks.map((task) => task.taskId)).toEqual(["R1", "R1.1", "R1.3", "R2"]); +}); + +test("mutateMdtodoDocument reports optimistic revision conflicts", () => { + const conflict = mutateMdtodoDocument("# Demo\n\n## R1 Root\n", { + type: "updateTitle", + rxxId: "R1", + title: "No write", + expectedFingerprint: "stale" + }); + + expect(conflict.ok).toBe(false); + expect(conflict.conflict).toBe(true); + expect(conflict.code).toBe("revision_conflict"); +}); diff --git a/internal/project-management/mdtodo.ts b/internal/project-management/mdtodo.ts index 3a595ebc..d7f22806 100644 --- a/internal/project-management/mdtodo.ts +++ b/internal/project-management/mdtodo.ts @@ -1,3 +1,7 @@ +/** + * SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source. + * Projects MDTODO Markdown source-of-truth into stable Rxx task projections and mutation helpers. + */ import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; @@ -15,6 +19,27 @@ const excludedDirectories = new Set([ ]); const taskLinePattern = /^(\s*)(?:[-*+]|\d+[.)])\s+\[([ xX-])\]\s+(.*)$/u; +const rxxHeadingPattern = /^(#{2,})\s+(R\d+(?:\.\d+)*)(?:\s+(.*?))?\s*$/iu; +const rxxIdPattern = /^R\d+(?:\.\d+)*$/iu; +const markdownLinkPattern = /!?\[[^\]]*\]\([^)]+\)|https?:\/\/\S+/giu; + +const statusAliases = new Map([ + ["open", "open"], + ["todo", "open"], + ["blocked", "blocked"], + ["in_progress", "in_progress"], + ["processing", "in_progress"], + ["completed", "done"], + ["finished", "done"], + ["done", "done"] +]); + +const statusMarkers = new Map([ + ["open", null], + ["blocked", "blocked"], + ["in_progress", "in_progress"], + ["done", "completed"] +]); export async function discoverMdtodoDocuments(options = {}) { const root = path.resolve(String(options.root || process.cwd())); @@ -45,6 +70,162 @@ export function parseMdtodoDocument(markdown, options = {}) { const fingerprint = sha256(markdown); const projectId = safeToken(options.projectId, projectIdForRelativePath(relativePath)); const lines = String(markdown ?? "").split(/\r?\n/u); + const context = { sourceId, relativePath, fileRef, fingerprint, projectId }; + const rxxProjection = parseRxxTasks(lines, context); + const projection = rxxProjection.tasks.length > 0 ? rxxProjection : parseLegacyCheckboxTasks(lines, context); + const parserMode = rxxProjection.tasks.length > 0 ? "rxx" : "legacy-checkbox"; + + return { + sourceId, + fileRef, + relativePath, + fingerprint, + revision: fingerprint, + projectId, + title: documentTitle(markdown, relativePath), + parserMode, + taskCount: projection.tasks.length, + tasks: projection.tasks, + document: { + source: "markdown-file", + relativePath, + firstHeading: documentTitle(markdown, relativePath), + parserMode, + revision: fingerprint, + parseDiagnostics: projection.diagnostics, + valuesRedacted: true + } + }; +} + +export function mutateMdtodoDocument(markdown, operation = {}, options = {}) { + const originalMarkdown = String(markdown ?? ""); + const previousFingerprint = sha256(originalMarkdown); + const expectedFingerprint = operation.expectedFingerprint ?? options.expectedFingerprint; + if (expectedFingerprint && expectedFingerprint !== previousFingerprint) { + return { + ok: false, + conflict: true, + code: "revision_conflict", + message: "MDTODO document fingerprint does not match expectedFingerprint", + expectedFingerprint, + actualFingerprint: previousFingerprint + }; + } + + const type = String(operation.type ?? "").trim(); + const lines = originalMarkdown.split(/\r?\n/u); + const blocks = collectRxxBlocks(lines); + let nextLines = lines.slice(); + let changedTaskId = null; + let affectedTaskIds = []; + + if (type === "updateTitle") { + const block = requireRxxBlock(blocks, operation.rxxId); + const title = cleanTaskTitle(operation.title); + nextLines[block.lineIndex] = formatRxxHeading(block.hashes, block.rxxId, block.status, title); + changedTaskId = block.rxxId; + affectedTaskIds = [block.rxxId]; + } else if (type === "updateStatus") { + const block = requireRxxBlock(blocks, operation.rxxId); + const status = requireStatus(operation.status); + nextLines[block.lineIndex] = formatRxxHeading(block.hashes, block.rxxId, status, block.title); + changedTaskId = block.rxxId; + affectedTaskIds = [block.rxxId]; + } else if (type === "updateBody") { + const block = requireRxxBlock(blocks, operation.rxxId); + const bodyLines = bodyReplacementLines(operation.body); + nextLines.splice(block.lineIndex + 1, Math.max(0, block.endLine - block.lineIndex - 1), ...bodyLines); + changedTaskId = block.rxxId; + affectedTaskIds = [block.rxxId]; + } else if (type === "addTask") { + const add = buildAddTaskMutation(lines, blocks, operation); + nextLines = insertLines(nextLines, add.insertPos, add.lines); + changedTaskId = add.rxxId; + affectedTaskIds = [add.rxxId]; + } else if (type === "deleteTask") { + const block = requireRxxBlock(blocks, operation.rxxId); + const endLine = subtreeEndLine(blocks, block.rxxId); + affectedTaskIds = blocks + .filter((candidate) => candidate.rxxId === block.rxxId || isDescendantRxxId(candidate.rxxId, block.rxxId)) + .map((candidate) => candidate.rxxId); + nextLines.splice(block.lineIndex, Math.max(1, endLine - block.lineIndex)); + changedTaskId = block.rxxId; + } else { + throw mutationError("unsupported_mutation", `Unsupported MDTODO mutation type: ${type || ""}`); + } + + const nextMarkdown = normalizeMarkdownOutput(nextLines); + const fingerprint = sha256(nextMarkdown); + const document = parseMdtodoDocument(nextMarkdown, options); + return { + ok: true, + conflict: false, + markdown: nextMarkdown, + previousFingerprint, + fingerprint, + revision: fingerprint, + changedTaskId, + affectedTaskIds, + taskCount: document.taskCount, + document + }; +} + +export function documentFileRef(relativePath) { + return `file_${shortHash(normalizeRelativePath(relativePath), 16)}`; +} + +function parseRxxTasks(lines, context) { + const diagnostics = []; + const blocks = collectRxxBlocks(lines); + const seen = new Set(); + const uniqueBlocks = []; + for (const block of blocks) { + if (seen.has(block.rxxId)) { + diagnostics.push({ code: "duplicate_rxx_id", rxxId: block.rxxId, lineNumber: block.lineNumber }); + continue; + } + seen.add(block.rxxId); + uniqueBlocks.push(block); + } + + const taskIds = new Set(uniqueBlocks.map((block) => block.rxxId)); + const tasks = uniqueBlocks.map((block, index) => { + const parentRxxId = parentRxxIdFor(block.rxxId); + const parentTaskRef = parentRxxId && taskIds.has(parentRxxId) ? taskRefForRxx(context.sourceId, context.fileRef, parentRxxId) : null; + if (parentRxxId && !parentTaskRef) diagnostics.push({ code: "missing_parent_rxx_id", rxxId: block.rxxId, parentRxxId, lineNumber: block.lineNumber }); + const bodyText = lines.slice(block.lineIndex + 1, block.endLine).join("\n"); + const bodyPreview = bodyPreviewFromLines(lines, block.lineIndex + 1, block.endLine); + const title = cleanTaskTitle(block.title || bodyPreview || block.rxxId); + return { + taskRef: taskRefForRxx(context.sourceId, context.fileRef, block.rxxId), + taskId: block.rxxId, + rxxId: block.rxxId, + projectId: context.projectId, + sourceId: context.sourceId, + fileRef: context.fileRef, + relativePath: context.relativePath, + title, + status: block.status, + parentTaskRef, + parentRxxId: parentTaskRef ? parentRxxId : null, + depth: rxxDepth(block.rxxId), + lineNumber: block.lineNumber, + ordinal: index + 1, + linkCount: countMarkdownLinks(bodyText), + sourceFingerprint: context.fingerprint, + parserMode: "rxx", + headingLevel: block.hashes.length, + statusMarker: block.statusMarker, + bodyPreview + }; + }); + + return { tasks, diagnostics }; +} + +function parseLegacyCheckboxTasks(lines, context) { const tasks = []; const parentStack = []; let ordinal = 0; @@ -56,16 +237,16 @@ export function parseMdtodoDocument(markdown, options = {}) { const depth = Math.max(0, Math.floor(match[1].replace(/\t/gu, " ").length / 2)); const checkbox = match[2]; const title = cleanTaskTitle(match[3]); - const taskId = `t${index + 1}_${shortHash(`${relativePath}:${index + 1}:${title}`)}`; + const taskId = `t${index + 1}_${shortHash(`${context.relativePath}:${index + 1}:${title}`)}`; const parentTaskRef = parentStack[depth - 1] ?? null; - const taskRef = `mdtodo:${sourceId}:${fileRef}:${taskId}`; + const taskRef = `mdtodo:${context.sourceId}:${context.fileRef}:${taskId}`; const task = { taskRef, taskId, - projectId, - sourceId, - fileRef, - relativePath, + projectId: context.projectId, + sourceId: context.sourceId, + fileRef: context.fileRef, + relativePath: context.relativePath, title, status: checkbox.toLowerCase() === "x" ? "done" : checkbox === "-" ? "blocked" : "open", parentTaskRef, @@ -73,38 +254,175 @@ export function parseMdtodoDocument(markdown, options = {}) { lineNumber: index + 1, ordinal, linkCount: 0, - sourceFingerprint: fingerprint + sourceFingerprint: context.fingerprint, + parserMode: "legacy-checkbox" }; tasks.push(task); parentStack[depth] = taskRef; parentStack.length = depth + 1; } + return { tasks, diagnostics: [] }; +} + +function collectRxxBlocks(lines) { + const headings = []; + for (let index = 0; index < lines.length; index += 1) { + const heading = parseRxxHeading(lines[index]); + if (heading) headings.push({ ...heading, lineIndex: index, lineNumber: index + 1 }); + } + return headings.map((heading, index) => ({ + ...heading, + endLine: headings[index + 1]?.lineIndex ?? lines.length + })); +} + +function parseRxxHeading(line) { + const match = rxxHeadingPattern.exec(String(line ?? "")); + if (!match) return null; + const parsed = parseHeadingRest(match[3] ?? ""); return { - sourceId, - fileRef, - relativePath, - fingerprint, - projectId, - title: documentTitle(markdown, relativePath), - taskCount: tasks.length, - tasks, - document: { - source: "markdown-file", - relativePath, - firstHeading: documentTitle(markdown, relativePath), - valuesRedacted: true - } + hashes: match[1], + rxxId: normalizeRxxId(match[2]), + status: parsed.status, + statusMarker: parsed.statusMarker, + title: parsed.title }; } -export function documentFileRef(relativePath) { - return `file_${shortHash(normalizeRelativePath(relativePath), 16)}`; +function parseHeadingRest(value) { + const rest = String(value ?? "").trim(); + let status = "open"; + let statusMarker = null; + for (const match of rest.matchAll(/\[([^\]]+)\]/gu)) { + const candidate = normalizeStatus(match[1]); + if (!candidate) continue; + if (statusRank(candidate) >= statusRank(status)) { + status = candidate; + statusMarker = match[1]; + } + } + return { + status, + statusMarker, + title: stripStatusMarkers(rest) + }; +} + +function buildAddTaskMutation(lines, blocks, operation) { + const title = cleanTaskTitle(operation.title); + const status = requireStatus(operation.status ?? "open"); + const parentRxxId = normalizeOptionalRxxId(operation.parentRxxId); + const afterRxxId = normalizeOptionalRxxId(operation.afterRxxId); + let rxxId; + let headingLevel; + let insertPos; + + if (parentRxxId) { + const parent = requireRxxBlock(blocks, parentRxxId); + rxxId = nextChildRxxId(blocks, parent.rxxId); + headingLevel = headingLevelForRxx(rxxId); + insertPos = subtreeEndLine(blocks, parent.rxxId); + } else if (afterRxxId) { + const after = requireRxxBlock(blocks, afterRxxId); + const siblingParent = parentRxxIdFor(after.rxxId); + rxxId = siblingParent ? nextChildRxxId(blocks, siblingParent) : nextRootRxxId(blocks); + headingLevel = headingLevelForRxx(rxxId); + insertPos = subtreeEndLine(blocks, after.rxxId); + } else { + rxxId = nextRootRxxId(blocks); + headingLevel = headingLevelForRxx(rxxId); + insertPos = logicalDocumentEnd(lines); + } + + const bodyLines = normalizeBodyLines(operation.body); + const taskLines = [formatRxxHeading("#".repeat(headingLevel), rxxId, status, title)]; + if (bodyLines.length > 0) taskLines.push("", ...bodyLines); + return { rxxId, insertPos, lines: taskLines }; +} + +function requireRxxBlock(blocks, value) { + const rxxId = normalizeOptionalRxxId(value); + if (!rxxId) throw mutationError("rxx_id_required", "A valid Rxx id is required"); + const block = blocks.find((candidate) => candidate.rxxId === rxxId); + if (!block) throw mutationError("rxx_id_not_found", `MDTODO task ${rxxId} was not found`, { rxxId }); + return block; +} + +function insertLines(lines, insertPos, newLines) { + const next = lines.slice(); + const insertion = []; + if (insertPos > 0 && String(next[insertPos - 1] ?? "").trim() !== "") insertion.push(""); + insertion.push(...newLines); + if (insertPos < next.length && String(next[insertPos] ?? "").trim() !== "") insertion.push(""); + next.splice(insertPos, 0, ...insertion); + return next; +} + +function bodyReplacementLines(value) { + const lines = normalizeBodyLines(value); + if (lines.length === 0) return []; + return ["", ...lines, ""]; +} + +function normalizeBodyLines(value) { + const text = String(value ?? "").replace(/\r\n/gu, "\n").trim(); + if (!text) return []; + return text.split("\n"); +} + +function formatRxxHeading(hashes, rxxId, status, title) { + const marker = statusMarkers.get(normalizeStatus(status) ?? "open"); + const parts = [hashes, rxxId]; + if (title) parts.push(cleanTaskTitle(title)); + if (marker) parts.push(`[${marker}]`); + return parts.join(" "); +} + +function nextRootRxxId(blocks) { + const next = Math.max(0, ...blocks + .map((block) => rxxSegments(block.rxxId)) + .filter((segments) => segments.length === 1) + .map((segments) => segments[0])) + 1; + return `R${next}`; +} + +function nextChildRxxId(blocks, parentRxxId) { + const parentSegments = rxxSegments(parentRxxId); + const prefix = `${parentRxxId}.`; + const next = Math.max(0, ...blocks + .filter((block) => block.rxxId.startsWith(prefix)) + .map((block) => rxxSegments(block.rxxId)) + .filter((segments) => segments.length === parentSegments.length + 1) + .map((segments) => segments.at(-1))) + 1; + return `${parentRxxId}.${next}`; +} + +function subtreeEndLine(blocks, rxxId) { + const startIndex = blocks.findIndex((block) => block.rxxId === rxxId); + if (startIndex < 0) return 0; + let endLine = blocks[startIndex].endLine; + for (let index = startIndex + 1; index < blocks.length; index += 1) { + if (!isDescendantRxxId(blocks[index].rxxId, rxxId)) break; + endLine = blocks[index].endLine; + } + return endLine; +} + +function logicalDocumentEnd(lines) { + let end = lines.length; + while (end > 0 && String(lines[end - 1] ?? "").trim() === "") end -= 1; + return end; +} + +function normalizeMarkdownOutput(lines) { + const text = lines.join("\n").replace(/[\t ]+$/gmu, "").replace(/\n{4,}/gu, "\n\n\n").trimEnd(); + return text ? `${text}\n` : ""; } function isMdtodoCandidate(filePath, markdown) { const basename = path.basename(filePath).toLowerCase(); - return basename.includes("todo") || basename.includes("mdtodo") || String(markdown ?? "").split(/\r?\n/u).some((line) => taskLinePattern.test(line)); + return basename.includes("todo") || basename.includes("mdtodo") || String(markdown ?? "").split(/\r?\n/u).some((line) => taskLinePattern.test(line) || rxxHeadingPattern.test(line)); } async function walkMarkdown(root, current, files, maxFiles) { @@ -140,6 +458,76 @@ function projectIdForRelativePath(relativePath) { return `project_${safeToken(candidate.toLowerCase().replace(/[^a-z0-9_.:-]+/gu, "_"), "default")}`; } +function taskRefForRxx(sourceId, fileRef, rxxId) { + return `mdtodo:${sourceId}:${fileRef}:${rxxId}`; +} + +function parentRxxIdFor(rxxId) { + const segments = String(rxxId ?? "").split("."); + if (segments.length <= 1) return null; + return segments.slice(0, -1).join("."); +} + +function isDescendantRxxId(candidate, parent) { + return candidate.startsWith(`${parent}.`); +} + +function rxxDepth(rxxId) { + return Math.max(0, rxxSegments(rxxId).length - 1); +} + +function headingLevelForRxx(rxxId) { + return Math.min(6, rxxDepth(rxxId) + 2); +} + +function rxxSegments(rxxId) { + return String(rxxId ?? "").replace(/^R/iu, "").split(".").map((segment) => Number(segment)).filter((segment) => Number.isInteger(segment) && segment > 0); +} + +function normalizeRxxId(value) { + return String(value ?? "").replace(/^r/iu, "R"); +} + +function normalizeOptionalRxxId(value) { + const text = normalizeRxxId(value).trim(); + return rxxIdPattern.test(text) ? text : null; +} + +function normalizeStatus(value) { + const key = String(value ?? "open").trim().toLowerCase().replace(/[\s-]+/gu, "_"); + return statusAliases.get(key) ?? null; +} + +function requireStatus(value) { + const status = normalizeStatus(value); + if (!status) throw mutationError("invalid_status", `Unsupported MDTODO status: ${String(value ?? "")}`); + return status; +} + +function statusRank(status) { + if (status === "done") return 3; + if (status === "in_progress") return 2; + if (status === "blocked") return 1; + return 0; +} + +function stripStatusMarkers(value) { + return String(value ?? "").replace(/\s*\[([^\]]+)\]/gu, (match, label) => normalizeStatus(label) ? "" : match).trim(); +} + +function countMarkdownLinks(value) { + return [...String(value ?? "").matchAll(markdownLinkPattern)].length; +} + +function bodyPreviewFromLines(lines, start, end) { + for (const line of lines.slice(start, end)) { + const text = String(line ?? "").trim(); + if (!text || rxxHeadingPattern.test(text)) continue; + return text.replace(/^[-*+]\s+/u, "").slice(0, 240); + } + return ""; +} + function cleanTaskTitle(value) { return String(value ?? "").replace(/\s+\s*$/u, "").trim().slice(0, 500) || "Untitled task"; } @@ -165,3 +553,10 @@ function positiveInteger(value, fallback) { const number = Number(value); return Number.isInteger(number) && number > 0 ? number : fallback; } + +function mutationError(code, message, details = {}) { + const error = new Error(message); + error.code = code; + Object.assign(error, details); + return error; +} diff --git a/internal/project-management/server.test.ts b/internal/project-management/server.test.ts index c7da39dc..f37a05bc 100644 --- a/internal/project-management/server.test.ts +++ b/internal/project-management/server.test.ts @@ -10,7 +10,7 @@ import { createProjectManagementStore } from "./store.ts"; test("project management app exposes MDTODO read model and Workbench link writes", async () => { const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-")); try { - await writeFile(join(root, "MDTODO.md"), "# Demo\n\n- [ ] Launch project nav\n- [x] Wire read model\n", "utf8"); + await writeFile(join(root, "MDTODO.md"), "# Demo\n\n## R1 Launch project nav\n\n## R2 Wire read model [completed]\n", "utf8"); const app = createProjectManagementApp({ env: {}, store: createProjectManagementStore({ kind: "memory" }), @@ -32,6 +32,7 @@ test("project management app exposes MDTODO read model and Workbench link writes expect(files.files[0].taskCount).toBe(2); const tasks = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=test-mdtodo"); + expect(tasks.tasks.map((task) => task.taskId)).toEqual(["R1", "R2"]); expect(tasks.tasks.map((task) => task.status)).toEqual(["open", "done"]); expect(tasks.tasks.every((task) => task.projectId === "project_test")).toBe(true);