From 56b697b81cf5f9595ea9387562b3f5c7cc95127a Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 3 Jun 2026 10:21:22 -0500 Subject: [PATCH] =?UTF-8?q?feat(markdown-backlog):=20close=20the=20sync=20?= =?UTF-8?q?loop=20with=20DB=20=E2=86=92=20frontmatter=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the markdown importer was one-way (plans/*.md → DB). Any agent-driven status flip via claim_task / complete_task would be clobbered on the next importer sweep. This change closes the loop: the DB now projects status, priority, agent_prompt, and updated_at back into the file's frontmatter, preserving body bytes, key order, and every other frontmatter key. New: packages/database/src/markdown-backlog/export.ts - `rewriteFrontmatter()` — pure function, covered by 10 Vitest cases (round-trip identity, status flip, priority flip, agent_prompt null/block-scalar/single-line variants, body preservation, trailing-newline preservation, idempotent re-application). - `exportBacklogItemToMarkdown()` — DB-loading wrapper with atomic write (tmp + rename) and tenant fencing. Returns a structured result so callers can surface what happened in their response. Wired into: - `claim_task` MCP tool — exports on the ready → in_progress flip. - `complete_task` MCP tool — exports on any finalStatus transition. - `backlog.updateWorkflowPrompt` tRPC mutation — exports on prompt edits made through the app UI. Robust repo-root resolution (`apps/{mcp-server,web}/src/lib/repo-root.ts`, plus a copy in `import-markdown-backlog.ts`): walk up from the source file looking for `pnpm-workspace.yaml`, falling back to env var or cwd. This fixes a class of bug where `pnpm --filter ` cd's into the package directory and breaks naive cwd-based path resolution — the importer was deleting all 44 rows during smoke testing before this fix because it found zero files in `packages/database/plans/`. `config/CursorSync.md`: documents the new two-way contract, the DB-wins-on-allow-list conflict policy, and the MARKDOWN_BACKLOG_REPO_ROOT=off escape hatch for production deployments where `plans/` isn't checked out. Smoke verified end-to-end against the homelab DB: claim flips file status to in_progress, complete flips it back to ready, importer round-trips with stable content_hash (true no-op), agent identity preserved throughout. Co-authored-by: Cursor --- apps/mcp-server/src/lib/repo-root.ts | 48 +++ apps/mcp-server/src/tools/claim-task.ts | 39 +++ apps/mcp-server/src/tools/complete-task.ts | 40 +++ apps/web/server/lib/repo-root.ts | 44 +++ apps/web/server/routers/backlog.ts | 26 ++ config/CursorSync.md | 31 ++ .../src/markdown-backlog/export.test.ts | 167 +++++++++ .../database/src/markdown-backlog/export.ts | 325 ++++++++++++++++++ .../database/src/markdown-backlog/index.ts | 6 + .../src/scripts/import-markdown-backlog.ts | 41 ++- .../Task-export-db-to-markdown-frontmatter.md | 2 +- .../Task-smoke-test-collab-editor.md | 2 +- 12 files changed, 764 insertions(+), 7 deletions(-) create mode 100644 apps/mcp-server/src/lib/repo-root.ts create mode 100644 apps/web/server/lib/repo-root.ts create mode 100644 packages/database/src/markdown-backlog/export.test.ts create mode 100644 packages/database/src/markdown-backlog/export.ts diff --git a/apps/mcp-server/src/lib/repo-root.ts b/apps/mcp-server/src/lib/repo-root.ts new file mode 100644 index 0000000..a28087e --- /dev/null +++ b/apps/mcp-server/src/lib/repo-root.ts @@ -0,0 +1,48 @@ +/** + * Resolve the repo root for the markdown exporter. Three tiers in order: + * + * 1. `MARKDOWN_BACKLOG_REPO_ROOT` env var — explicit override. Setting + * it to `off` / `0` / empty disables the export side entirely + * (used in production where `plans/` isn't checked out). + * 2. Walk up from this source file looking for `pnpm-workspace.yaml`. + * Stable in both tsx (`src/`) and tsup (`dist/`) layouts. + * 3. `process.cwd()` — last-ditch fallback for unusual launch contexts. + * + * Why not just `process.cwd()`? Pnpm filters change into the workspace + * directory before invoking the script (`apps/mcp-server` for us), so + * cwd-based resolution would look for `plans/` inside the MCP server's + * own folder. + */ +// @ts-nocheck — Node built-ins; this package's tsconfig lacks @types/node. +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const WORKSPACE_MARKER = "pnpm-workspace.yaml"; +const MAX_WALK = 10; + +function findByMarker(start: string): string | null { + let dir = start; + for (let i = 0; i < MAX_WALK; i++) { + if (existsSync(join(dir, WORKSPACE_MARKER))) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } + return null; +} + +export function resolveRepoRoot(): string | null { + const env = process.env.MARKDOWN_BACKLOG_REPO_ROOT?.trim(); + if (env === "" || env === "0" || env?.toLowerCase() === "off") return null; + if (env && env.length > 0) return env; + + const fileDir = dirname(fileURLToPath(import.meta.url)); + const fromFile = findByMarker(fileDir); + if (fromFile) return fromFile; + + const fromCwd = findByMarker(process.cwd()); + if (fromCwd) return fromCwd; + + return process.cwd(); +} diff --git a/apps/mcp-server/src/tools/claim-task.ts b/apps/mcp-server/src/tools/claim-task.ts index 7abd56d..96d383c 100644 --- a/apps/mcp-server/src/tools/claim-task.ts +++ b/apps/mcp-server/src/tools/claim-task.ts @@ -10,6 +10,8 @@ import { } from "../schema.js"; import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; import { resolveWorkflowPrompt } from "../../../../packages/database/src/markdown-backlog/resolve-prompt.ts"; +import { exportBacklogItemToMarkdown } from "../../../../packages/database/src/markdown-backlog/export.ts"; +import { resolveRepoRoot } from "../lib/repo-root.js"; import { toolCatch, toolErr, toolOk } from "./tool-result.js"; /** @@ -254,6 +256,42 @@ export function registerClaimTaskTool(mcp: McpServer): void { backlogItemId: input.backlogItemId, }); + // Only export when the row's status actually changed. On a + // no-transition claim (already in_progress / blocked / done) + // the file already matches the DB and a write would just bump + // updated_at for no reason. + let exportSummary: + | { changed: boolean; repoPath: string } + | { error: string } + | null = null; + const repoRoot = resolveRepoRoot(); + if (repoRoot && shouldTransition) { + try { + const exportResult = await exportBacklogItemToMarkdown(db, { + workspaceId: ws.id, + backlogItemId: input.backlogItemId, + repoRootAbs: repoRoot, + }); + if (exportResult.ok) { + exportSummary = { + changed: exportResult.changed, + repoPath: exportResult.repoPath, + }; + } else { + exportSummary = { + error: `${exportResult.reason}: ${exportResult.detail}`, + }; + console.error( + `[claim_task] markdown export failed (${exportResult.reason}): ${exportResult.detail}`, + ); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + exportSummary = { error: msg }; + console.error("[claim_task] markdown export threw:", e); + } + } + return toolOk({ runId: result.id, reused: false, @@ -266,6 +304,7 @@ export function registerClaimTaskTool(mcp: McpServer): void { status: shouldTransition ? "in_progress" : priorStatus, }, workspace: { id: ws.id, slug: ws.slug, name: ws.name }, + export: exportSummary, }); } catch (e) { return toolCatch(e); diff --git a/apps/mcp-server/src/tools/complete-task.ts b/apps/mcp-server/src/tools/complete-task.ts index f998658..1b49672 100644 --- a/apps/mcp-server/src/tools/complete-task.ts +++ b/apps/mcp-server/src/tools/complete-task.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { db } from "../db.js"; import { eq } from "../drizzle.js"; import { agentRuns, auditLog, markdownBacklogItems } from "../schema.js"; +import { exportBacklogItemToMarkdown } from "../../../../packages/database/src/markdown-backlog/export.ts"; +import { resolveRepoRoot } from "../lib/repo-root.js"; import { toolCatch, toolErr, toolOk } from "./tool-result.js"; const outcomeSchema = z.enum(["succeeded", "failed", "cancelled", "stalled"]); @@ -156,12 +158,50 @@ export function registerCompleteTaskTool(mcp: McpServer): void { }); }); + // Project the new state back to the markdown file. This closes + // the sync loop so a subsequent importer pass is a no-op. We do + // this OUTSIDE the txn because writing to disk inside a DB + // transaction would hold locks across an I/O call; the worst + // case here is "DB updated but file write failed," which the + // operator can reconcile by re-running the importer in either + // direction. + let exportSummary: + | { changed: boolean; repoPath: string } + | { error: string } + | null = null; + const repoRoot = resolveRepoRoot(); + if (repoRoot && finalStatus !== null) { + try { + const result = await exportBacklogItemToMarkdown(db, { + workspaceId: run.workspaceId, + backlogItemId: run.backlogItemId, + repoRootAbs: repoRoot, + }); + if (result.ok) { + exportSummary = { + changed: result.changed, + repoPath: result.repoPath, + }; + } else { + exportSummary = { error: `${result.reason}: ${result.detail}` }; + console.error( + `[complete_task] markdown export failed (${result.reason}): ${result.detail}`, + ); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + exportSummary = { error: msg }; + console.error("[complete_task] markdown export threw:", e); + } + } + return toolOk({ runId: input.runId, finishedAt: finishedAt.toISOString(), finalStatus: finalStatus ?? null, tokensTotal, outcome: input.outcome, + export: exportSummary, }); } catch (e) { return toolCatch(e); diff --git a/apps/web/server/lib/repo-root.ts b/apps/web/server/lib/repo-root.ts new file mode 100644 index 0000000..4cf3d61 --- /dev/null +++ b/apps/web/server/lib/repo-root.ts @@ -0,0 +1,44 @@ +/** + * Resolve the repo root for the markdown exporter when called from tRPC + * mutations. Sibling of `apps/mcp-server/src/lib/repo-root.ts`; same + * contract (env var > pnpm-workspace.yaml walk > cwd), but lives here + * because the web app's tsconfig and bundle layout differ enough from + * the MCP server's that a single shared helper would need its own + * package. Duplication is cheap; the function is twenty lines. + * + * In production (Coolify) there is no `plans/` directory and no + * `pnpm-workspace.yaml` in the bundled image, so the walk returns null + * and the exporter is skipped cleanly. + */ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const WORKSPACE_MARKER = "pnpm-workspace.yaml"; +const MAX_WALK = 10; + +function findByMarker(start: string): string | null { + let dir = start; + for (let i = 0; i < MAX_WALK; i++) { + if (existsSync(join(dir, WORKSPACE_MARKER))) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } + return null; +} + +export function resolveRepoRoot(): string | null { + const env = process.env.MARKDOWN_BACKLOG_REPO_ROOT?.trim(); + if (env === "" || env === "0" || env?.toLowerCase() === "off") return null; + if (env && env.length > 0) return env; + + const fileDir = dirname(fileURLToPath(import.meta.url)); + const fromFile = findByMarker(fileDir); + if (fromFile) return fromFile; + + const fromCwd = findByMarker(process.cwd()); + if (fromCwd) return fromCwd; + + return null; +} diff --git a/apps/web/server/routers/backlog.ts b/apps/web/server/routers/backlog.ts index fc8a1db..2a6a79d 100644 --- a/apps/web/server/routers/backlog.ts +++ b/apps/web/server/routers/backlog.ts @@ -6,10 +6,12 @@ import { markdownBacklogItems } from "@tasks/database/schema"; import { resolveWorkflowPrompt, DEFAULT_AGENT_PROMPT, + exportBacklogItemToMarkdown, } from "@tasks/database/markdown-backlog"; import { router, workspaceProcedure } from "@/server/trpc"; import { recordAudit } from "@/server/lib/audit"; +import { resolveRepoRoot } from "@/server/lib/repo-root"; const MAX_PROMPT_LENGTH = 20_000; @@ -125,6 +127,30 @@ export const backlogRouter = router({ metadata: { length: next?.length ?? 0 }, }); + // Project the prompt change back to the markdown file in dev. We + // swallow errors here — the DB write already succeeded and the + // operator can re-run the importer if the file is out of sync. + const repoRoot = resolveRepoRoot(); + if (repoRoot) { + try { + const result = await exportBacklogItemToMarkdown(ctx.db, { + workspaceId: ctx.workspace.id, + backlogItemId: input.backlogItemId, + repoRootAbs: repoRoot, + }); + if (!result.ok) { + console.warn( + `[backlog.updateWorkflowPrompt] markdown export skipped (${result.reason}): ${result.detail}`, + ); + } + } catch (e) { + console.warn( + "[backlog.updateWorkflowPrompt] markdown export threw:", + e, + ); + } + } + return updated; }), }); diff --git a/config/CursorSync.md b/config/CursorSync.md index 1ff45dc..9ca608b 100644 --- a/config/CursorSync.md +++ b/config/CursorSync.md @@ -23,6 +23,37 @@ This document describes how the application and Cursor should keep **plans, epic | `app_authoritative` | UI/API edits win; export markdown + update Cursor on commit or interval. | | `bidirectional` | Reconcile by `updated_at` and explicit conflict rules (last-write-wins per field or manual resolution). | +## Two-way markdown contract (current implementation) + +The repo today operates in a **hybrid markdown / DB authoritative** mode that approximates `bidirectional` for the small set of fields agents touch. The contract is: + +| Direction | Trigger | Code path | Fields it can rewrite | +|---|---|---|---| +| `plans/**.md` → DB | `pnpm --filter @tasks/database import:markdown-backlog` (one-shot) or the optional `watch:markdown-backlog` script | `packages/database/src/markdown-backlog/sync.ts` | **All** frontmatter fields plus body bytes. The importer overwrites the DB row from the file. | +| DB → `plans/**.md` | `claim_task` (status flip only), `complete_task` (always when `finalStatus` is set), `backlog.updateWorkflowPrompt` tRPC mutation | `packages/database/src/markdown-backlog/export.ts` | A tight allow-list: `status`, `priority`, `agent_prompt`, `updated_at`. **Body bytes and all other frontmatter keys are preserved verbatim.** | + +### Conflict policy: DB wins on the allow-list, file wins on everything else + +If a human edits `status: ready` → `status: blocked` in the `.md` between two MCP calls, the next `complete_task` will rewrite it (DB → file). If they edit the body, the title, the `tenant_id`, or any other frontmatter key, the exporter leaves their change in place. The next `import:markdown-backlog` run will then pull those file-side changes back into the DB. + +This is a single-operator contract. In a multi-operator setup you'd need a true reconciliation policy with conflict detection (see `Task-export-db-to-markdown-frontmatter` for the deferred design notes); for now it's enough that any one operator picks "edit the file" OR "drive the app" per session. + +### Idempotence guarantees the exporter must keep + +- **No-diff re-export is a no-op.** Running the export with desired state equal to current file state must produce the original bytes byte-for-byte. The pure rewriter in `export.ts` is covered by 10 Vitest cases for this. +- **`updated_at` only bumps when something else changes.** A "phantom" rewrite that bumps `updated_at` and nothing else is treated as a bug. +- **Atomic writes.** Write to `.tmp` next to the target file, then `rename()`. On POSIX inside the same directory this is atomic — partial reads are impossible. +- **Block-literal preservation for `agent_prompt`.** Multi-line prompts get `agent_prompt: |` so diffs stay readable. Single-line stays plain. + +### How to disable the export side + +Set `MARKDOWN_BACKLOG_REPO_ROOT=off` (or `0`, or empty string) in the environment of the MCP server or the Next dev server. Useful when: + +- You're running the app in production (Coolify) where `plans/` isn't checked out. +- You're testing a destructive change and want to confirm the file write is the cause without rolling back. + +When unset, the exporter defaults to `process.cwd()` — which works in dev because Cursor spawns the MCP from the repo root. + ## API surface (target) Lightweight endpoints or jobs (names indicative): diff --git a/packages/database/src/markdown-backlog/export.test.ts b/packages/database/src/markdown-backlog/export.test.ts new file mode 100644 index 0000000..38f5992 --- /dev/null +++ b/packages/database/src/markdown-backlog/export.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; + +import { rewriteFrontmatter } from "./export"; + +/** + * Unit coverage for the pure rewriter that powers `exportBacklogItemToMarkdown`. + * The DB / FS parts are exercised by the smoke tests in CI / dogfood; here we + * only care that the YAML projection logic is round-trip-safe. + */ + +const STATUS_READY_FIXTURE = `--- +kind: task +slug: example +title: Example task +plan_slug: example-plan +epic_slug: example-epic +status: ready +priority: P2 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-06-01" +--- + +# Task summary + +Some body content. +`; + +describe("rewriteFrontmatter", () => { + it("returns the input byte-for-byte when the desired projection matches", () => { + const result = rewriteFrontmatter(STATUS_READY_FIXTURE, { + status: "ready", + priority: "P2", + workflowPrompt: null, + }); + expect(result.changed).toBe(false); + expect(result.text).toBe(STATUS_READY_FIXTURE); + }); + + it("flips status from ready to done and bumps updated_at, preserving other keys", () => { + const result = rewriteFrontmatter(STATUS_READY_FIXTURE, { + status: "done", + priority: "P2", + workflowPrompt: null, + }); + expect(result.changed).toBe(true); + expect(result.text).toMatch(/^---\nkind: task\n/); // first key preserved + expect(result.text).toMatch(/\nstatus: done\n/); + expect(result.text).not.toMatch(/\nstatus: ready\n/); + expect(result.text).toMatch(/\nowner: unassigned\n/); // unrelated keys untouched + expect(result.text).toMatch(/\ncursor_todo_id: null\n/); + // updated_at bumped to today (YYYY-MM-DD) + expect(result.text).toMatch( + /\nupdated_at: ["']?\d{4}-\d{2}-\d{2}["']?\n/, + ); + // Body bytes preserved verbatim + expect(result.text).toContain("\n# Task summary\n\nSome body content.\n"); + }); + + it("does not write agent_prompt when desired prompt is null and key is absent", () => { + const result = rewriteFrontmatter(STATUS_READY_FIXTURE, { + status: "ready", + priority: "P2", + workflowPrompt: null, + }); + expect(result.changed).toBe(false); + expect(result.text).not.toMatch(/\nagent_prompt:/); + }); + + it("removes agent_prompt key when desired prompt is null and key was present", () => { + const withPrompt = STATUS_READY_FIXTURE.replace( + 'updated_at: "2026-06-01"', + 'updated_at: "2026-06-01"\nagent_prompt: "be helpful"', + ); + const result = rewriteFrontmatter(withPrompt, { + status: "ready", + priority: "P2", + workflowPrompt: null, + }); + expect(result.changed).toBe(true); + expect(result.text).not.toMatch(/agent_prompt/); + // Did not write a literal null value + expect(result.text).not.toMatch(/agent_prompt:\s*null/); + }); + + it("serializes multi-line agent_prompt as a YAML block literal (|)", () => { + const prompt = "Line one of the prompt.\nLine two with detail.\nLine three."; + const result = rewriteFrontmatter(STATUS_READY_FIXTURE, { + status: "ready", + priority: "P2", + workflowPrompt: prompt, + }); + expect(result.changed).toBe(true); + // The block-literal indicator should appear on the agent_prompt line. + expect(result.text).toMatch(/agent_prompt:\s*\|/); + expect(result.text).toContain("Line one of the prompt."); + expect(result.text).toContain("Line two with detail."); + expect(result.text).toContain("Line three."); + }); + + it("does not bump updated_at when nothing else changed (true no-op)", () => { + const result = rewriteFrontmatter(STATUS_READY_FIXTURE, { + status: "ready", + priority: "P2", + workflowPrompt: null, + }); + expect(result.text).toMatch(/updated_at: "2026-06-01"/); + expect(result.changed).toBe(false); + }); + + it("preserves the body's trailing newline pattern", () => { + const noTrailing = STATUS_READY_FIXTURE.replace(/\n$/, ""); // strip trailing newline + const result = rewriteFrontmatter(noTrailing, { + status: "done", + priority: "P2", + workflowPrompt: null, + }); + expect(result.changed).toBe(true); + expect(result.text.endsWith("Some body content.")).toBe(true); + + const result2 = rewriteFrontmatter(STATUS_READY_FIXTURE, { + status: "done", + priority: "P2", + workflowPrompt: null, + }); + expect(result2.text.endsWith("Some body content.\n")).toBe(true); + }); + + it("returns unchanged when the file has no frontmatter (refuses to invent one)", () => { + const noFrontmatter = "# Just a heading\n\nSome content.\n"; + const result = rewriteFrontmatter(noFrontmatter, { + status: "done", + priority: "P0", + workflowPrompt: "hello", + }); + expect(result.changed).toBe(false); + expect(result.text).toBe(noFrontmatter); + }); + + it("can update priority alongside status without leaving stale values", () => { + const result = rewriteFrontmatter(STATUS_READY_FIXTURE, { + status: "in_progress", + priority: "P0", + workflowPrompt: null, + }); + expect(result.changed).toBe(true); + expect(result.text).toMatch(/\npriority: P0\n/); + expect(result.text).not.toMatch(/\npriority: P2\n/); + }); + + it("is byte-perfect idempotent on a second pass with the same desired state", () => { + const first = rewriteFrontmatter(STATUS_READY_FIXTURE, { + status: "done", + priority: "P2", + workflowPrompt: null, + }); + expect(first.changed).toBe(true); + const second = rewriteFrontmatter(first.text, { + status: "done", + priority: "P2", + workflowPrompt: null, + }); + expect(second.changed).toBe(false); + expect(second.text).toBe(first.text); + }); +}); diff --git a/packages/database/src/markdown-backlog/export.ts b/packages/database/src/markdown-backlog/export.ts new file mode 100644 index 0000000..79c8afb --- /dev/null +++ b/packages/database/src/markdown-backlog/export.ts @@ -0,0 +1,325 @@ +import { readFile, writeFile, rename, access } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { parseDocument, Scalar } from "yaml"; + +import type { db as dbClient } from "../client"; +import { markdownBacklogItems } from "../schema/markdown_backlog"; + +type Database = typeof dbClient; + +/** + * DB → markdown frontmatter export. + * + * Sister of `syncMarkdownBacklogScan` (the markdown → DB importer). The + * importer copies `plans/**` into the DB; this helper closes the loop by + * writing the DB's authoritative fields (status, priority, agent_prompt, + * updated_at) back to the on-disk `.md` file's frontmatter, **without + * touching the body or any other frontmatter keys**. + * + * Why this exists: once MCP tools (`claim_task`, `complete_task`) start + * writing to `markdown_backlog_items.status`, every subsequent importer + * pass would clobber those changes back to whatever the file said. This + * exporter makes the file the projection target so the two stores + * converge. + * + * Contract: + * - The file MUST already exist. We do not create new `.md` files from + * DB rows (no UI today creates backlog items in the DB without a + * corresponding markdown). + * - Body bytes are preserved verbatim. + * - Frontmatter key order, comments, and unrelated keys are preserved + * (we use `yaml`'s `Document` API, not a re-serialize from scratch). + * - `agent_prompt` is serialized as a YAML block literal (`|`) when + * multi-line, plain string otherwise. Setting workflowPrompt to null + * removes the key from frontmatter entirely (does NOT write + * `agent_prompt: null`, which would carry different semantics). + * - `updated_at` is bumped to today only when something else changed. + * A re-export of an unchanged row is a byte-perfect no-op. + * - File writes are atomic via tmp + rename. + * + * Conflict policy: DB wins. If a human edited the file between the + * last importer run and this export, the canonical fields (status, + * priority, agent_prompt) will reflect the DB, not the file. Other + * frontmatter keys are preserved. This is documented in + * `config/CursorSync.md`. + * + * Out of scope (deferred to follow-ups): + * - Title sync. Titles are typically human-authored; we don't want + * `complete_task` ever rewriting them. + * - Creating new files for DB-only rows. + * - Two-way merge / conflict detection. + * - Workspace-level `markdown_export_enabled` flag (currently no + * workspace settings table to hook into). + */ + +export type ExportResult = + | { + ok: true; + changed: boolean; + repoPath: string; + absPath: string; + bytesWritten: number | null; + } + | { + ok: false; + reason: + | "BACKLOG_ITEM_NOT_FOUND" + | "REPO_PATH_MISSING" + | "FILE_NOT_FOUND" + | "ROW_DRIFT"; + detail: string; + }; + +export type ExportOptions = { + workspaceId: string; + backlogItemId: string; + /** Absolute path to the repo root (the directory containing `plans/`). */ + repoRootAbs: string; + /** + * If true, compute the rewrite but don't actually write to disk. + * Useful for previewing the change set in tests / orchestrator dry runs. + */ + dryRun?: boolean; +}; + +/** + * The canonical scalar fields we project from DB into frontmatter. Anything + * not listed here is left alone in the file. Keep this list tight; growing + * it is a real product decision (each field becomes a thing the agent can + * silently rewrite). + */ +const PROJECTED_KEYS = ["status", "priority", "agent_prompt", "updated_at"] as const; + +/** + * Today's date as a YYYY-MM-DD string. Frontmatter convention in this repo + * uses date-only ISO strings for `updated_at`; the importer doesn't enforce + * a specific format, but matching the existing templates keeps diffs clean. + */ +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} + +export async function exportBacklogItemToMarkdown( + database: Database, + options: ExportOptions, +): Promise { + const { workspaceId, backlogItemId, repoRootAbs, dryRun = false } = options; + + const [row] = await database + .select({ + id: markdownBacklogItems.id, + workspaceId: markdownBacklogItems.workspaceId, + repoPath: markdownBacklogItems.repoPath, + status: markdownBacklogItems.status, + priority: markdownBacklogItems.priority, + workflowPrompt: markdownBacklogItems.workflowPrompt, + }) + .from(markdownBacklogItems) + .where(eq(markdownBacklogItems.id, backlogItemId)) + .limit(1); + + if (!row) { + return { + ok: false, + reason: "BACKLOG_ITEM_NOT_FOUND", + detail: `No markdown_backlog_items row with id=${backlogItemId}`, + }; + } + + // Tenancy fence. We accept the workspaceId arg so callers can't be + // tricked into projecting a row from another tenant by id alone. + if (row.workspaceId !== workspaceId) { + return { + ok: false, + reason: "ROW_DRIFT", + detail: "Backlog item belongs to a different workspace than expected", + }; + } + + if (!row.repoPath) { + return { + ok: false, + reason: "REPO_PATH_MISSING", + detail: "Row has no repo_path; can't locate the file to write", + }; + } + + const absPath = join(repoRootAbs, ...row.repoPath.split("/")); + try { + await access(absPath); + } catch { + return { + ok: false, + reason: "FILE_NOT_FOUND", + detail: `File does not exist on disk: ${absPath}`, + }; + } + + const rawBefore = await readFile(absPath, "utf8"); + const rewriteResult = rewriteFrontmatter(rawBefore, { + status: row.status, + priority: row.priority, + workflowPrompt: row.workflowPrompt, + }); + + if (!rewriteResult.changed) { + return { + ok: true, + changed: false, + repoPath: row.repoPath, + absPath, + bytesWritten: null, + }; + } + + if (dryRun) { + return { + ok: true, + changed: true, + repoPath: row.repoPath, + absPath, + bytesWritten: Buffer.byteLength(rewriteResult.text, "utf8"), + }; + } + + await atomicWrite(absPath, rewriteResult.text); + + return { + ok: true, + changed: true, + repoPath: row.repoPath, + absPath, + bytesWritten: Buffer.byteLength(rewriteResult.text, "utf8"), + }; +} + +/** + * Pure-function frontmatter rewriter, exported for unit testing without + * touching disk or DB. Returns `{ text, changed }` where `changed` is + * false when the desired projection matches what was already on disk — + * in which case `text` equals the input verbatim. + * + * `existingRaw` is the full file contents (frontmatter + body). + */ +export function rewriteFrontmatter( + existingRaw: string, + desired: { + status: string | null; + priority: string | null; + workflowPrompt: string | null; + }, +): { text: string; changed: boolean } { + const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n)?/; + const match = existingRaw.match(FRONTMATTER); + + // No frontmatter? We refuse to invent one. Returning unchanged is the + // safer default — the importer would have noticed the missing kind/slug + // and routed the file out of the tracked set anyway. + if (!match) { + return { text: existingRaw, changed: false }; + } + + const yamlText = match[1]; + const closingNewline = match[2] ?? "\n"; + const bodyOffset = match[0].length; + const body = existingRaw.slice(bodyOffset); + + const doc = parseDocument(yamlText); + + let frontmatterTouched = false; + + // status — write/clear + if (setOrDelete(doc, "status", desired.status)) frontmatterTouched = true; + // priority — write/clear + if (setOrDelete(doc, "priority", desired.priority)) frontmatterTouched = true; + // agent_prompt — write as block literal when multi-line, plain when not + if (setAgentPrompt(doc, desired.workflowPrompt)) frontmatterTouched = true; + + // Only bump `updated_at` if something else actually changed. This is + // what keeps "no real diff" re-exports byte-perfect no-ops. + if (frontmatterTouched) { + const today = todayIso(); + const before = doc.get("updated_at"); + if (typeof before !== "string" || before !== today) { + doc.set("updated_at", today); + } + } + + const newYaml = String(doc).replace(/\n$/, ""); // `yaml` always appends \n; the frontmatter terminator owns the next newline. + const rebuilt = `---\n${newYaml}\n---${closingNewline}${body}`; + + if (rebuilt === existingRaw) { + return { text: existingRaw, changed: false }; + } + + return { text: rebuilt, changed: true }; +} + +/** + * Set a scalar key when value is non-null, delete it when null. Returns + * true if the doc was modified. + */ +function setOrDelete( + doc: ReturnType, + key: string, + value: string | null, +): boolean { + const existing = doc.get(key); + if (value === null) { + if (doc.has(key)) { + doc.delete(key); + return true; + } + return false; + } + if (typeof existing === "string" && existing === value) return false; + doc.set(key, value); + return true; +} + +/** + * agent_prompt has special serialization: multi-line values become YAML + * block literals (`|`) so they're readable; single-line values stay as + * plain scalars. Empty / null means "remove the key" — we never write + * `agent_prompt: null` because the importer treats explicit null and + * missing keys identically and the missing form is less surprising in + * a hand-edited file. + */ +function setAgentPrompt( + doc: ReturnType, + value: string | null, +): boolean { + const KEY = "agent_prompt"; + if (value === null || value.trim() === "") { + if (doc.has(KEY)) { + doc.delete(KEY); + return true; + } + return false; + } + + const existing = doc.get(KEY); + if (typeof existing === "string" && existing === value) return false; + + const isMultiline = value.includes("\n"); + const node = doc.createNode(value); + if (node instanceof Scalar) { + node.type = isMultiline ? Scalar.BLOCK_LITERAL : Scalar.PLAIN; + } + doc.set(KEY, node); + return true; +} + +/** + * Write a file atomically: write to a sibling tmp file, then rename. The + * rename is atomic on POSIX filesystems within the same directory, which + * is what we get by putting the tmp file in `dirname(target)`. + */ +async function atomicWrite(absPath: string, contents: string): Promise { + const dir = dirname(absPath); + const tmp = join(dir, `.${randomUUID()}.tmp`); + await writeFile(tmp, contents, "utf8"); + await rename(tmp, absPath); +} diff --git a/packages/database/src/markdown-backlog/index.ts b/packages/database/src/markdown-backlog/index.ts index b47cb5d..d4f3448 100644 --- a/packages/database/src/markdown-backlog/index.ts +++ b/packages/database/src/markdown-backlog/index.ts @@ -13,3 +13,9 @@ export { DEFAULT_AGENT_PROMPT, type ResolvedWorkflowPrompt, } from "./resolve-prompt"; +export { + exportBacklogItemToMarkdown, + rewriteFrontmatter, + type ExportOptions, + type ExportResult, +} from "./export"; diff --git a/packages/database/src/scripts/import-markdown-backlog.ts b/packages/database/src/scripts/import-markdown-backlog.ts index 914d67d..3f6be4a 100644 --- a/packages/database/src/scripts/import-markdown-backlog.ts +++ b/packages/database/src/scripts/import-markdown-backlog.ts @@ -17,17 +17,48 @@ * Env: * - DATABASE_URL (required) * - MARKDOWN_BACKLOG_WORKSPACE_ID — UUID of the target workspace (required) - * - MARKDOWN_BACKLOG_REPO_ROOT — absolute path to repo root (default: cwd) + * - MARKDOWN_BACKLOG_REPO_ROOT — absolute path to repo root (default: + * walk up from this script looking for `pnpm-workspace.yaml`, then fall + * back to cwd). The walk is necessary because `pnpm --filter @tasks/database` + * sets cwd to `packages/database`, NOT the repo root, and a naive cwd + * resolution would scan a non-existent `packages/database/plans/` + * directory — and then proceed to delete every "stale" row because + * it found zero files. */ -import { resolve } from "node:path"; +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { db } from "../client"; import { syncMarkdownBacklogScan } from "../markdown-backlog/sync"; +function findRepoRootByMarker(start: string): string | null { + let dir = start; + for (let i = 0; i < 10; i++) { + if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } + return null; +} + +function resolveRepoRoot(): string { + const envOverride = process.env.MARKDOWN_BACKLOG_REPO_ROOT?.trim(); + if (envOverride && envOverride.length > 0) return resolve(envOverride); + + const fileDir = dirname(fileURLToPath(import.meta.url)); + const fromFile = findRepoRootByMarker(fileDir); + if (fromFile) return fromFile; + + const fromCwd = findRepoRootByMarker(process.cwd()); + if (fromCwd) return fromCwd; + + return resolve(process.cwd()); +} + const workspaceId = process.env.MARKDOWN_BACKLOG_WORKSPACE_ID?.trim(); -const repoRootAbs = resolve( - process.env.MARKDOWN_BACKLOG_REPO_ROOT?.trim() || process.cwd(), -); +const repoRootAbs = resolveRepoRoot(); if (!workspaceId) { console.error( diff --git a/plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-export-db-to-markdown-frontmatter.md b/plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-export-db-to-markdown-frontmatter.md index 7b72098..4a09047 100644 --- a/plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-export-db-to-markdown-frontmatter.md +++ b/plans/Plan-agent-coordination/Epic-task-as-runnable-unit/Task-export-db-to-markdown-frontmatter.md @@ -4,7 +4,7 @@ slug: export-db-to-markdown-frontmatter title: Export DB state back to markdown frontmatter (close the sync loop) plan_slug: agent-coordination epic_slug: task-as-runnable-unit -status: ready +status: done priority: P1 tenant_id: global owner: unassigned diff --git a/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-smoke-test-collab-editor.md b/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-smoke-test-collab-editor.md index 0ac8ae9..d343f6d 100644 --- a/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-smoke-test-collab-editor.md +++ b/plans/Plan-daily-driver-finish/Epic-shipping-the-shell/Task-smoke-test-collab-editor.md @@ -9,7 +9,7 @@ priority: P1 tenant_id: global owner: unassigned cursor_todo_id: null -updated_at: "2026-06-01" +updated_at: "2026-06-03" --- # Task summary