diff --git a/CHANGELOG.md b/CHANGELOG.md index be729c30..20c33d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Critical:** work_finish now re-validates PR mergeable status during conflict resolution cycles, preventing infinite loops where developers claim "fixed" without pushing changes (#482, #480, #464, #483) +- **Workspace root file ownership boundary** — Startup now updates only DevClaw-managed tagged sections inside `AGENTS.md`, `HEARTBEAT.md`, and `TOOLS.md`, preserving user content outside those blocks while keeping explicit reset flows able to rewrite full defaults (#88) +- **Defaults loading in source/test runs** — Template resolution now finds `defaults/` correctly in both bundled and source layouts, restoring setup/workspace tests and local development flows ### Improved diff --git a/lib/dispatch/bootstrap-hook.test.ts b/lib/dispatch/bootstrap-hook.test.ts index 38fe7d17..5b086368 100644 --- a/lib/dispatch/bootstrap-hook.test.ts +++ b/lib/dispatch/bootstrap-hook.test.ts @@ -5,6 +5,7 @@ import { describe, it } from "node:test"; import assert from "node:assert"; import { parseDevClawSessionKey, loadRoleInstructions } from "./bootstrap-hook.js"; +import { DEFAULT_ROLE_INSTRUCTIONS } from "../setup/templates.js"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; @@ -81,10 +82,19 @@ describe("loadRoleInstructions", () => { await fs.rm(tmpDir, { recursive: true }); }); - it("should return empty string when no instructions exist", async () => { + it("should fall back to package defaults when no workspace instructions exist", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); const result = await loadRoleInstructions(tmpDir, "missing", "developer"); + assert.strictEqual(result, DEFAULT_ROLE_INSTRUCTIONS.developer); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should return empty string for unknown roles with no defaults", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); + + const result = await loadRoleInstructions(tmpDir, "missing", "unknown-role"); assert.strictEqual(result, ""); await fs.rm(tmpDir, { recursive: true }); diff --git a/lib/services/bootstrap.e2e.test.ts b/lib/services/bootstrap.e2e.test.ts index 4d5ae4d6..23127f5d 100644 --- a/lib/services/bootstrap.e2e.test.ts +++ b/lib/services/bootstrap.e2e.test.ts @@ -102,8 +102,8 @@ describe("E2E bootstrap — extraSystemPrompt injection", () => { }); const prompts = h.commands.extraSystemPrompts(); - // No prompt files exist in this temp workspace — extraSystemPrompt should be absent - assert.strictEqual(prompts.length, 0, "No extraSystemPrompt when no prompt files exist"); + assert.strictEqual(prompts.length, 1, "Default scaffolded prompt should be injected"); + assert.match(prompts[0], /Developer/i); }); it("should resolve tester instructions independently from developer", async () => { diff --git a/lib/setup/onboarding.ts b/lib/setup/onboarding.ts index 3d087798..7b7d8047 100644 --- a/lib/setup/onboarding.ts +++ b/lib/setup/onboarding.ts @@ -59,11 +59,11 @@ Models are configured in \`devclaw/workflow.yaml\`. Edit that file directly or c ## What can be changed 1. **Model levels** — call \`setup\` with a \`models\` object containing only the levels to change -2. **Workspace files** — \`setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files) +2. **Workspace files** — \`setup\` seeds missing workspace files, and normal startup refreshes only DevClaw-managed tagged blocks inside AGENTS.md / HEARTBEAT.md / TOOLS.md 3. **Register new projects** — use \`project_register\` Ask what they want to change, then call the appropriate tool. -\`setup\` is safe to re-run — it backs up existing files before overwriting. +Use \`setup({ resetDefaults: true })\` only when they explicitly want package defaults force-written again. `; } diff --git a/lib/setup/templates.ts b/lib/setup/templates.ts index 0bfef758..3740bb5b 100644 --- a/lib/setup/templates.ts +++ b/lib/setup/templates.ts @@ -12,9 +12,25 @@ import path from "node:path"; // File loader — reads from defaults/ (single source of truth) // --------------------------------------------------------------------------- -// esbuild bundles everything into dist/index.js, so import.meta.url points to -// dist/index.js → one level up reaches the repo root where defaults/ lives. -const DEFAULTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "defaults"); +function resolveDefaultsDir(): string { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + // Source layout: lib/setup/templates.ts -> repo root is ../.. + path.join(moduleDir, "..", "..", "defaults"), + // Bundled layout: dist/index.js -> repo root is .. + path.join(moduleDir, "..", "defaults"), + // Last resort for unusual invocation cwd + path.join(process.cwd(), "defaults"), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + throw new Error(`Failed to locate defaults/ directory. Tried: ${candidates.join(", ")}`); +} + +const DEFAULTS_DIR = resolveDefaultsDir(); function loadDefault(filename: string): string { const filePath = path.join(DEFAULTS_DIR, filename); diff --git a/lib/setup/workspace.test.ts b/lib/setup/workspace.test.ts index b8671362..b76685da 100644 --- a/lib/setup/workspace.test.ts +++ b/lib/setup/workspace.test.ts @@ -1,8 +1,8 @@ /** - * workspace.test.ts — Tests for write-once default file behavior. + * workspace.test.ts — Tests for default workspace file behavior. * - * Verifies that ensureDefaultFiles() creates missing files but never - * overwrites user-owned config (workflow.yaml, prompts, IDENTITY.md). + * Verifies that ensureDefaultFiles() preserves user-owned config and only + * manages tagged DevClaw blocks in workspace-root guidance files. * * Run: npx tsx --test lib/setup/workspace.test.ts */ @@ -11,7 +11,7 @@ import assert from "node:assert"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; -import { ensureDefaultFiles, fileExists } from "./workspace.js"; +import { ensureDefaultFiles, fileExists, writeAllDefaults } from "./workspace.js"; import { DATA_DIR } from "./migrate-layout.js"; let tmpDir: string; @@ -27,7 +27,11 @@ afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }); }); -describe("ensureDefaultFiles — write-once behavior", () => { +function escapeRegexForTest(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +describe("ensureDefaultFiles — managed root-block behavior", () => { it("should create workflow.yaml when missing", async () => { const ws = await makeTmpDir(); await ensureDefaultFiles(ws); @@ -100,15 +104,238 @@ describe("ensureDefaultFiles — write-once behavior", () => { assert.strictEqual(afterContent, customIdentity, "IDENTITY.md should not be overwritten"); }); - it("should always overwrite AGENTS.md (system instructions)", async () => { + it("should scaffold AGENTS.md with a managed block when missing", async () => { + const ws = await makeTmpDir(); + + await ensureDefaultFiles(ws); + + const agentsPath = path.join(ws, "AGENTS.md"); + const content = await fs.readFile(agentsPath, "utf-8"); + assert.match(content, //); + assert.match(content, //); + }); + + it("should insert a managed notice and block into an existing AGENTS.md without destroying user content", async () => { const ws = await makeTmpDir(); const agentsPath = path.join(ws, "AGENTS.md"); - await fs.writeFile(agentsPath, "# Old agents content", "utf-8"); + const before = "# My workspace rules\n\nKeep this.\n\n## After\nStill mine.\n"; + await fs.writeFile(agentsPath, before, "utf-8"); + await ensureDefaultFiles(ws); await ensureDefaultFiles(ws); const afterContent = await fs.readFile(agentsPath, "utf-8"); - assert.notStrictEqual(afterContent, "# Old agents content", "AGENTS.md should be overwritten"); + const blockCount = (afterContent.match(//g) ?? []).length; + const noticeCount = (afterContent.match(//g) ?? []).length; + assert.strictEqual(blockCount, 1, "managed block should not be duplicated"); + assert.strictEqual(noticeCount, 1, "managed notice should not be duplicated"); + assert.match(afterContent, /^# My workspace rules/m); + assert.match(afterContent, /Keep this\./); + assert.match(afterContent, /^## After$/m); + assert.match(afterContent, /Still mine\./); + assert.match(afterContent, /[\s\S]*may replace those changes the next time it refreshes defaults\.[\s\S]*/); + assert.match(afterContent, /[\s\S]*/); + }); + + it("should update an existing managed block, seed its notice, and avoid duplication", async () => { + const ws = await makeTmpDir(); + const toolsPath = path.join(ws, "TOOLS.md"); + await fs.writeFile( + toolsPath, + "Intro\n\n\nold block\n\n\nOutro\n", + "utf-8", + ); + + await ensureDefaultFiles(ws); + await ensureDefaultFiles(ws); + + const afterContent = await fs.readFile(toolsPath, "utf-8"); + const blockCount = (afterContent.match(//g) ?? []).length; + const noticeCount = (afterContent.match(//g) ?? []).length; + assert.strictEqual(blockCount, 1, "managed block should not be duplicated"); + assert.strictEqual(noticeCount, 1, "managed notice should not be duplicated"); + assert.match(afterContent, /^Intro/m); + assert.match(afterContent, /^Outro/m); + assert.match(afterContent, /Add workspace-specific tool notes outside the managed block\./); + assert.ok(!afterContent.includes("old block"), "managed block should be refreshed"); + }); + + it("should append only the missing block when the managed notice already exists", async () => { + const ws = await makeTmpDir(); + const toolsPath = path.join(ws, "TOOLS.md"); + await fs.writeFile( + toolsPath, + [ + "Intro", + "", + "", + "stale notice", + "", + "", + "Outro", + "", + ].join("\n"), + "utf-8", + ); + + await ensureDefaultFiles(ws); + await ensureDefaultFiles(ws); + + const afterContent = await fs.readFile(toolsPath, "utf-8"); + const blockCount = (afterContent.match(//g) ?? []).length; + const noticeCount = (afterContent.match(//g) ?? []).length; + + assert.strictEqual(blockCount, 1, "should insert exactly one managed block"); + assert.strictEqual(noticeCount, 1, "should not duplicate the managed notice"); + assert.match(afterContent, /^Intro/m); + assert.match(afterContent, /^Outro$/m); + assert.match(afterContent, /Add workspace-specific tool notes outside the managed block\./); + assert.ok(!afterContent.includes("stale notice"), "existing notice should be refreshed"); + }); + + it("should normalize legacy full-file DevClaw root docs into a single managed block without duplication", async () => { + const ws = await makeTmpDir(); + const legacyFiles = [ + { + fileName: "AGENTS.md", + sectionId: "agents", + template: (await import("./templates.js")).AGENTS_MD_TEMPLATE, + }, + { + fileName: "HEARTBEAT.md", + sectionId: "heartbeat", + template: (await import("./templates.js")).HEARTBEAT_MD_TEMPLATE, + }, + { + fileName: "TOOLS.md", + sectionId: "tools", + template: (await import("./templates.js")).TOOLS_MD_TEMPLATE, + }, + ]; + + for (const { fileName, sectionId, template } of legacyFiles) { + const filePath = path.join(ws, fileName); + const legacyContent = template.replace(/\n/g, "\r\n"); + await fs.writeFile(filePath, legacyContent, "utf-8"); + + await ensureDefaultFiles(ws); + await ensureDefaultFiles(ws); + + const afterContent = await fs.readFile(filePath, "utf-8"); + const noticeCount = (afterContent.match(new RegExp(``, "g")) ?? []).length; + const blockCount = (afterContent.match(new RegExp(``, "g")) ?? []).length; + + assert.strictEqual(noticeCount, 1, `${fileName} notice should be normalized exactly once`); + assert.strictEqual(blockCount, 1, `${fileName} block should be normalized exactly once`); + assert.doesNotMatch(afterContent, new RegExp(`${escapeRegexForTest(template.trim())}\\s*`), `${fileName} should not retain the legacy full-file template above the managed block`); + } + }); + + it("should collapse the real legacy-plus-tagged duplicate shape into one managed section", async () => { + const ws = await makeTmpDir(); + const legacyFiles = [ + { + fileName: "AGENTS.md", + sectionId: "agents", + template: (await import("./templates.js")).AGENTS_MD_TEMPLATE, + }, + { + fileName: "HEARTBEAT.md", + sectionId: "heartbeat", + template: (await import("./templates.js")).HEARTBEAT_MD_TEMPLATE, + }, + { + fileName: "TOOLS.md", + sectionId: "tools", + template: (await import("./templates.js")).TOOLS_MD_TEMPLATE, + }, + ]; + + for (const { fileName, sectionId, template } of legacyFiles) { + const filePath = path.join(ws, fileName); + const duplicatedLegacy = `${template.trimEnd()}\n\n\nstale notice\n\n\n\nstale block\n\n`; + await fs.writeFile(filePath, duplicatedLegacy, "utf-8"); + + await ensureDefaultFiles(ws); + await ensureDefaultFiles(ws); + + const afterContent = await fs.readFile(filePath, "utf-8"); + const noticeCount = (afterContent.match(new RegExp(``, "g")) ?? []).length; + const blockCount = (afterContent.match(new RegExp(``, "g")) ?? []).length; + + assert.strictEqual(noticeCount, 1, `${fileName} should keep one managed notice after normalization`); + assert.strictEqual(blockCount, 1, `${fileName} should keep one managed block after normalization`); + assert.doesNotMatch(afterContent, new RegExp(`^${escapeRegexForTest(template.trim())}\\s*`, "m"), `${fileName} should not keep the legacy template above the managed section`); + assert.ok(!afterContent.includes("stale notice"), `${fileName} should refresh any stale notice`); + assert.ok(!afterContent.includes("stale block"), `${fileName} should refresh any stale block`); + } + }); + + it("should preserve customized HEARTBEAT.md content outside managed sections across restarts", async () => { + const ws = await makeTmpDir(); + const heartbeatPath = path.join(ws, "HEARTBEAT.md"); + const customHeartbeat = "Before\n\nAfter heartbeat\n"; + await fs.writeFile(heartbeatPath, customHeartbeat, "utf-8"); + + await ensureDefaultFiles(ws); + await ensureDefaultFiles(ws); + + const heartbeat = await fs.readFile(heartbeatPath, "utf-8"); + const noticeCount = (heartbeat.match(//g) ?? []).length; + const blockCount = (heartbeat.match(//g) ?? []).length; + assert.strictEqual(noticeCount, 1, "managed notice should not be duplicated"); + assert.strictEqual(blockCount, 1, "managed block should not be duplicated"); + assert.match(heartbeat, /^Before/m); + assert.match(heartbeat, /^After heartbeat$/m); + assert.match(heartbeat, //); + assert.match(heartbeat, //); + assert.ok(heartbeat.includes("Before\n\nAfter heartbeat"), "custom heartbeat content should survive restart"); + }); + + it("should preserve customized TOOLS.md content outside managed sections across restarts", async () => { + const ws = await makeTmpDir(); + const toolsPath = path.join(ws, "TOOLS.md"); + const customTools = "Before tools\n\nAfter tools\n"; + await fs.writeFile(toolsPath, customTools, "utf-8"); + + await ensureDefaultFiles(ws); + await ensureDefaultFiles(ws); + + const tools = await fs.readFile(toolsPath, "utf-8"); + const noticeCount = (tools.match(//g) ?? []).length; + const blockCount = (tools.match(//g) ?? []).length; + assert.strictEqual(noticeCount, 1, "managed notice should not be duplicated"); + assert.strictEqual(blockCount, 1, "managed block should not be duplicated"); + assert.match(tools, /^Before tools/m); + assert.match(tools, /^After tools$/m); + assert.match(tools, //); + assert.match(tools, //); + assert.ok(tools.includes("Before tools\n\nAfter tools"), "custom tools content should survive restart"); + }); + + it("should let explicit reset/default-writing flows overwrite intentionally", async () => { + const ws = await makeTmpDir(); + const agentsPath = path.join(ws, "AGENTS.md"); + await fs.writeFile(agentsPath, "# Custom root file\n", "utf-8"); + + const written = await writeAllDefaults(ws, true); + const afterContent = await fs.readFile(agentsPath, "utf-8"); + + assert.ok(written.includes("AGENTS.md")); + assert.ok(!afterContent.includes("# Custom root file"), "reset-defaults should replace the full file"); + assert.ok(afterContent.includes("DevClaw"), "reset-defaults should restore the package template"); + }); + + it("should let eject-defaults skip existing customized root files", async () => { + const ws = await makeTmpDir(); + const toolsPath = path.join(ws, "TOOLS.md"); + await fs.writeFile(toolsPath, "# Custom tools\n", "utf-8"); + + const written = await writeAllDefaults(ws, false); + const afterContent = await fs.readFile(toolsPath, "utf-8"); + + assert.ok(!written.includes("TOOLS.md")); + assert.strictEqual(afterContent, "# Custom tools\n"); }); it("should write .version file", async () => { diff --git a/lib/setup/workspace.ts b/lib/setup/workspace.ts index 00d13e5b..57d87950 100644 --- a/lib/setup/workspace.ts +++ b/lib/setup/workspace.ts @@ -3,8 +3,9 @@ * * On startup, ensureDefaultFiles() creates missing workspace files with curated * defaults. User-owned config files (workflow.yaml, prompts, IDENTITY.md) are - * write-once: created if missing, never overwritten. System instruction files - * (AGENTS.md, HEARTBEAT.md, TOOLS.md) are always refreshed. + * write-once: created if missing, never overwritten. Workspace-root guidance + * files (AGENTS.md, HEARTBEAT.md, TOOLS.md) remain user-owned documents; on + * startup DevClaw only manages its tagged section inside each file. * * The runtime config loader (lib/config/loader.ts) uses a three-layer merge with * built-in fallbacks, so missing keys in workflow.yaml are handled automatically. @@ -30,29 +31,41 @@ import { log as auditLog } from "../audit.js"; /** Sentinel file indicating the workspace has been initialized. */ const INITIALIZED_SENTINEL = ".initialized"; +const MANAGED_BLOCKS = { + "AGENTS.md": { + sectionId: "agents", + template: AGENTS_MD_TEMPLATE, + intro: "DevClaw manages only the tagged block below on startup. You may edit any content outside that block. If you edit inside it, DevClaw may replace those changes the next time it refreshes defaults.", + }, + "HEARTBEAT.md": { + sectionId: "heartbeat", + template: HEARTBEAT_MD_TEMPLATE, + intro: "DevClaw manages only the tagged block below on startup. Keep any custom heartbeat notes outside the managed block.", + }, + "TOOLS.md": { + sectionId: "tools", + template: TOOLS_MD_TEMPLATE, + intro: "DevClaw manages only the tagged block below on startup. Add workspace-specific tool notes outside the managed block.", + }, +} as const; + /** * Ensure all workspace data files are up to date. * * Called on every heartbeat startup. * * File categories: - * - System instructions (AGENTS.md, HEARTBEAT.md, TOOLS.md): always overwrite + * - Root guidance (AGENTS.md, HEARTBEAT.md, TOOLS.md): update/create DevClaw tagged block only * - User-owned config (workflow.yaml, prompts, IDENTITY.md): create-only * - Runtime state (projects.json): create-only */ export async function ensureDefaultFiles(workspacePath: string): Promise { - const dataDir = path.join(workspacePath, DATA_DIR); - await fs.mkdir(dataDir, { recursive: true }); - - // Ensure directories exist - await fs.mkdir(path.join(dataDir, "projects"), { recursive: true }); - await fs.mkdir(path.join(dataDir, "prompts"), { recursive: true }); - await fs.mkdir(path.join(dataDir, "log"), { recursive: true }); + await ensureWorkspaceDataFiles(workspacePath); - // --- System instruction files — always overwrite with latest --- - await backupAndWrite(path.join(workspacePath, "AGENTS.md"), AGENTS_MD_TEMPLATE); - await backupAndWrite(path.join(workspacePath, "HEARTBEAT.md"), HEARTBEAT_MD_TEMPLATE); - await backupAndWrite(path.join(workspacePath, "TOOLS.md"), TOOLS_MD_TEMPLATE); + // --- Workspace-root guidance files — manage tagged DevClaw blocks only --- + for (const [fileName, config] of Object.entries(MANAGED_BLOCKS)) { + await upsertManagedBlock(path.join(workspacePath, fileName), config.sectionId, config.template, config.intro); + } // --- User-owned files — create-only, never overwrite --- @@ -64,6 +77,20 @@ export async function ensureDefaultFiles(workspacePath: string): Promise { // Remove BOOTSTRAP.md — one-time onboarding file, not needed after setup try { await fs.unlink(path.join(workspacePath, "BOOTSTRAP.md")); } catch { /* already gone */ } +} + +/** + * Ensure DevClaw-owned data files exist without touching workspace-root guidance files. + * Safe for generic runtime code paths like project reads. + */ +export async function ensureWorkspaceDataFiles(workspacePath: string): Promise { + const dataDir = path.join(workspacePath, DATA_DIR); + await fs.mkdir(dataDir, { recursive: true }); + + // Ensure directories exist + await fs.mkdir(path.join(dataDir, "projects"), { recursive: true }); + await fs.mkdir(path.join(dataDir, "prompts"), { recursive: true }); + await fs.mkdir(path.join(dataDir, "log"), { recursive: true }); // devclaw/workflow.yaml — create-only (three-layer merge handles defaults for missing keys) const workflowPath = path.join(dataDir, "workflow.yaml"); @@ -194,6 +221,119 @@ export async function backupAndWrite(filePath: string, content: string): Promise await fs.writeFile(filePath, content, "utf-8"); } +function buildManagedBlock(sectionId: string, content: string): string { + const start = ``; + const end = ``; + return `${start}\n${content.trimEnd()}\n${end}`; +} + +function buildManagedNotice(sectionId: string, intro: string): string { + return [ + ``, + intro, + ``, + ].join("\n"); +} + +function buildManagedSection(sectionId: string, content: string, intro: string): string { + return `${buildManagedNotice(sectionId, intro)}\n\n${buildManagedBlock(sectionId, content)}`; +} + +function buildManagedFile(sectionId: string, content: string, intro: string): string { + return `${buildManagedSection(sectionId, content, intro)}\n`; +} + +function normalizeForManagedComparison(content: string): string { + return content + .replace(/^\uFEFF/, "") + .replace(/\r\n?/g, "\n") + .trim(); +} + +function isLegacyManagedFullFile(originalContent: string, template: string): boolean { + return normalizeForManagedComparison(originalContent) === normalizeForManagedComparison(template); +} + +function stripManagedSection(originalContent: string, sectionId: string): string { + const managedSectionPattern = new RegExp( + `\n*[\\s\\S]*?\n*`, + "m", + ); + return originalContent.replace(managedSectionPattern, "\n"); +} + +function isLegacyManagedFileWithTaggedDuplicate(originalContent: string, sectionId: string, template: string): boolean { + return isLegacyManagedFullFile(stripManagedSection(originalContent, sectionId), template); +} + +function upsertManagedContent(originalContent: string, sectionId: string, content: string, intro: string): string { + const managedBlock = buildManagedBlock(sectionId, content); + const managedNotice = buildManagedNotice(sectionId, intro); + const managedSection = buildManagedSection(sectionId, content, intro); + const blockPattern = new RegExp( + `[\\s\\S]*?`, + "m", + ); + const noticePattern = new RegExp( + `[\\s\\S]*?\\n*`, + "m", + ); + + if ( + !originalContent.trim() + || isLegacyManagedFullFile(originalContent, content) + || isLegacyManagedFileWithTaggedDuplicate(originalContent, sectionId, content) + ) { + return buildManagedFile(sectionId, content, intro); + } + + const hasNotice = noticePattern.test(originalContent); + const hasBlock = blockPattern.test(originalContent); + + // Step 1: seed or refresh the explanatory notice independently from block insertion. + let nextContent = hasNotice + ? originalContent.replace(noticePattern, `${managedNotice}\n\n`) + : originalContent; + + if (!hasNotice && hasBlock) { + const blockMatch = nextContent.match(blockPattern); + if (!blockMatch || typeof blockMatch.index !== "number") { + return nextContent.endsWith("\n") ? nextContent : `${nextContent}\n`; + } + + const beforeBlock = nextContent.slice(0, blockMatch.index).replace(/\n+$/, ""); + const afterBlock = nextContent.slice(blockMatch.index); + nextContent = beforeBlock + ? `${beforeBlock}\n\n${managedNotice}\n\n${afterBlock}` + : `${managedNotice}\n\n${afterBlock}`; + } + + // Step 2: replace or append the managed block, preserving user-owned content. + if (hasBlock) { + nextContent = nextContent.replace(blockPattern, managedBlock); + return nextContent.endsWith("\n") ? nextContent : `${nextContent}\n`; + } + + const baseContent = nextContent.replace(/\n+$/, ""); + const separator = baseContent.endsWith("\n") ? "\n" : "\n\n"; + const insertedContent = hasNotice ? managedBlock : managedSection; + return `${baseContent}${separator}${insertedContent}\n`; +} + +async function upsertManagedBlock(filePath: string, sectionId: string, content: string, intro: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + + const existingContent = await fs.readFile(filePath, "utf-8").catch(() => ""); + const nextContent = upsertManagedContent(existingContent, sectionId, content, intro); + + if (existingContent === nextContent) return; + await fs.writeFile(filePath, nextContent, "utf-8"); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + /** * Write a file only if it doesn't exist. Returns true if file was written. */