Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion lib/dispatch/bootstrap-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
4 changes: 2 additions & 2 deletions lib/services/bootstrap.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions lib/setup/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
`;
}

Expand Down
22 changes: 19 additions & 3 deletions lib/setup/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
243 changes: 235 additions & 8 deletions lib/setup/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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, /<!-- DEVCLAW:START agents -->/);
assert.match(content, /<!-- DEVCLAW:END agents -->/);
});

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(/<!-- DEVCLAW:START agents -->/g) ?? []).length;
const noticeCount = (afterContent.match(/<!-- DEVCLAW:NOTICE:START agents -->/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, /<!-- DEVCLAW:NOTICE:START agents -->[\s\S]*may replace those changes the next time it refreshes defaults\.[\s\S]*<!-- DEVCLAW:NOTICE:END agents -->/);
assert.match(afterContent, /<!-- DEVCLAW:START agents -->[\s\S]*<!-- DEVCLAW:END agents -->/);
});

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<!-- DEVCLAW:START tools -->\nold block\n<!-- DEVCLAW:END tools -->\n\nOutro\n",
"utf-8",
);

await ensureDefaultFiles(ws);
await ensureDefaultFiles(ws);

const afterContent = await fs.readFile(toolsPath, "utf-8");
const blockCount = (afterContent.match(/<!-- DEVCLAW:START tools -->/g) ?? []).length;
const noticeCount = (afterContent.match(/<!-- DEVCLAW:NOTICE:START tools -->/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",
"",
"<!-- DEVCLAW:NOTICE:START tools -->",
"stale notice",
"<!-- DEVCLAW:NOTICE:END tools -->",
"",
"Outro",
"",
].join("\n"),
"utf-8",
);

await ensureDefaultFiles(ws);
await ensureDefaultFiles(ws);

const afterContent = await fs.readFile(toolsPath, "utf-8");
const blockCount = (afterContent.match(/<!-- DEVCLAW:START tools -->/g) ?? []).length;
const noticeCount = (afterContent.match(/<!-- DEVCLAW:NOTICE:START tools -->/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(`<!-- DEVCLAW:NOTICE:START ${sectionId} -->`, "g")) ?? []).length;
const blockCount = (afterContent.match(new RegExp(`<!-- DEVCLAW:START ${sectionId} -->`, "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*<!-- DEVCLAW:START ${sectionId} -->`), `${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<!-- DEVCLAW:NOTICE:START ${sectionId} -->\nstale notice\n<!-- DEVCLAW:NOTICE:END ${sectionId} -->\n\n<!-- DEVCLAW:START ${sectionId} -->\nstale block\n<!-- DEVCLAW:END ${sectionId} -->\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(`<!-- DEVCLAW:NOTICE:START ${sectionId} -->`, "g")) ?? []).length;
const blockCount = (afterContent.match(new RegExp(`<!-- DEVCLAW:START ${sectionId} -->`, "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*<!-- DEVCLAW:NOTICE:START ${sectionId} -->`, "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(/<!-- DEVCLAW:NOTICE:START heartbeat -->/g) ?? []).length;
const blockCount = (heartbeat.match(/<!-- DEVCLAW:START heartbeat -->/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, /<!-- DEVCLAW:NOTICE:START heartbeat -->/);
assert.match(heartbeat, /<!-- DEVCLAW:START 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(/<!-- DEVCLAW:NOTICE:START tools -->/g) ?? []).length;
const blockCount = (tools.match(/<!-- DEVCLAW:START tools -->/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, /<!-- DEVCLAW:NOTICE:START tools -->/);
assert.match(tools, /<!-- DEVCLAW:START 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 () => {
Expand Down
Loading