feat(markdown-backlog): close the sync loop with DB → frontmatter export

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 <pkg>` 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 <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-03 10:21:22 -05:00
parent fc2235a346
commit 56b697b81c
12 changed files with 764 additions and 7 deletions

View file

@ -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();
}

View file

@ -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);

View file

@ -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);

View file

@ -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;
}

View file

@ -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;
}),
});

View file

@ -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):

View file

@ -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);
});
});

View file

@ -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<ExportResult> {
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<typeof parseDocument>,
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<typeof parseDocument>,
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<void> {
const dir = dirname(absPath);
const tmp = join(dir, `.${randomUUID()}.tmp`);
await writeFile(tmp, contents, "utf8");
await rename(tmp, absPath);
}

View file

@ -13,3 +13,9 @@ export {
DEFAULT_AGENT_PROMPT,
type ResolvedWorkflowPrompt,
} from "./resolve-prompt";
export {
exportBacklogItemToMarkdown,
rewriteFrontmatter,
type ExportOptions,
type ExportResult,
} from "./export";

View file

@ -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(

View file

@ -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

View file

@ -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