Skip to content
Merged
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
58 changes: 58 additions & 0 deletions server/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -4140,6 +4140,32 @@ function reseedAgentsMd(existingContent, freshContent) {
};
}

// #966: Distinctive H2 headings that only appear in QuadWork's seeded
// `templates/CLAUDE.md`. A worktree CLAUDE.md is treated as QuadWork-seeded
// (and therefore safe to refresh) ONLY when it contains ALL of these — a
// project's own hand-authored CLAUDE.md (e.g. this repo's, whose H2s are
// "What is QuadWork", "Tech Stack", …) won't, so it is never clobbered. These
// three headings have been stable across template versions, so a file seeded
// by an older template is still recognized.
const SEEDED_CLAUDE_SIGNATURE = Object.freeze([
"Multi-Agent System",
"GitHub Workflow",
"Communication Rules",
]);

// #966: True only when `content` is a QuadWork-seeded CLAUDE.md — i.e. it
// carries every heading in SEEDED_CLAUDE_SIGNATURE (case-insensitive,
// whitespace-normalized, same comparison the merger uses). Empty/foreign
// content returns false so reseed leaves it untouched. Pure — no I/O.
function _isSeededClaudeMd(content) {
if (!content || !content.trim()) return false;
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
const headings = new Set(
_splitAgentsMdSections(content).blocks.map((b) => norm(b.heading))
);
return SEEDED_CLAUDE_SIGNATURE.every((h) => headings.has(norm(h)));
}

// #854: Extract the reviewer token path from a worktree's existing AGENTS.md
// so the re-seed substitution preserves a custom path instead of resetting
// it to the default. The re1/re2 seed renders:
Expand Down Expand Up @@ -4360,6 +4386,36 @@ function _performReseedWrites(project, cfg, opts = {}) {
if (merged.preservedHeadings.length > 0) {
preserved[agentKey] = merged.preservedHeadings;
}

// #966: Refresh the worktree CLAUDE.md too — but ONLY when it is provably
// QuadWork-seeded, so a project's own hand-authored CLAUDE.md is never
// clobbered. Uses the same reseedAgentsMd merge (fresh template wins
// per-H2, operator-added H2 sections preserved). Missing or foreign
// CLAUDE.md is left byte-identical and recorded in `skipped`.
const claudeMd = path.join(wtDir, "CLAUDE.md");
if (!fs.existsSync(claudeMd)) {
skipped.push(`${agentKey}/CLAUDE.md (none)`);
} else {
let claudeExisting = "";
try { claudeExisting = fs.readFileSync(claudeMd, "utf-8"); } catch { claudeExisting = ""; }
if (!_isSeededClaudeMd(claudeExisting)) {
skipped.push(`${agentKey}/CLAUDE.md (not QuadWork-seeded)`);
} else {
const claudeSrc = path.join(TEMPLATES_DIR, "CLAUDE.md");
if (!fs.existsSync(claudeSrc)) {
skipped.push(`${agentKey}/CLAUDE.md (no template)`);
} else {
const freshClaude = fs.readFileSync(claudeSrc, "utf-8")
.replace(/\{\{project_name\}\}/g, dirName);
const mergedClaude = reseedAgentsMd(claudeExisting, freshClaude);
fs.writeFileSync(claudeMd, mergedClaude.content);
reseeded.push(`${agentKey}/CLAUDE.md`);
if (mergedClaude.preservedHeadings.length > 0) {
preserved[`${agentKey}/CLAUDE.md`] = mergedClaude.preservedHeadings;
}
}
}
}
}
return { reseeded, skipped, preserved };
}
Expand Down Expand Up @@ -5101,6 +5157,8 @@ module.exports.isBatchActiveFromProgress = isBatchActiveFromProgress;
// custom-section preservation contract is exercisable without spinning up a
// real worktree. Pure function; no production callers outside this file.
module.exports.reseedAgentsMd = reseedAgentsMd;
// #966: expose the QuadWork-seeded CLAUDE.md predicate for unit tests.
module.exports._isSeededClaudeMd = _isSeededClaudeMd;
// #855: expose the per-agent target resolver + legacy-key map so the legacy
// config (`reviewer1` / `reviewer2` / `t1..t3`) → canonical-template mapping
// is exercisable without spinning up a real worktree. Pure helpers; no
Expand Down
181 changes: 181 additions & 0 deletions server/routes.reseedClaudeMd.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// #966: reseed must refresh a worktree's QuadWork-seeded CLAUDE.md alongside
// AGENTS.md, but must NEVER clobber a project's own hand-authored CLAUDE.md.
// Covers the pure `_isSeededClaudeMd` predicate plus the endpoint-level
// behavior against temp worktrees:
// • seeded CLAUDE.md is refreshed and an operator-added H2 is preserved
// • foreign (hand-authored) CLAUDE.md is left byte-identical (skipped)
// • worktree with no CLAUDE.md is handled without error (skipped)
// • AGENTS.md reseed behavior is unchanged (still refreshed)
//
// Plain node:assert script — auto-discovered by the #836 runner. Run directly
// with `node server/routes.reseedClaudeMd.test.js`.

const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const http = require("http");
const crypto = require("crypto");

const CONFIG_PATH = path.join(os.homedir(), ".quadwork", "config.json");
const TEMPLATES_DIR = path.join(__dirname, "..", "templates");

// ── Stub fs.readFileSync for CONFIG_PATH BEFORE requiring routes ──────────
// (Same hermetic pattern as routes.reseedTokenPreservation.test.js.)
const realReadFileSync = fs.readFileSync;
let stubCfgJson = JSON.stringify({ projects: [] });
fs.readFileSync = function stubReadFileSync(p, ...rest) {
if (p === CONFIG_PATH) return stubCfgJson;
return realReadFileSync(p, ...rest);
};

const express = require("express");
const routes = require("./routes");
const { _isSeededClaudeMd } = routes;

async function withServer(fn) {
const app = express();
app.use(express.json());
app.use(routes.router || routes);
await new Promise((resolve, reject) => {
const server = app.listen(0, "127.0.0.1", async () => {
const { port } = server.address();
try { await fn(`http://127.0.0.1:${port}`); resolve(); }
catch (e) { reject(e); }
finally { server.close(); }
});
});
}

function post(url, body) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const data = JSON.stringify(body || {});
const req = http.request({
hostname: u.hostname, port: u.port, path: u.pathname, method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
}, (res) => {
let chunks = "";
res.on("data", (c) => chunks += c);
res.on("end", () => {
try { resolve({ status: res.statusCode, body: JSON.parse(chunks || "{}") }); }
catch (e) { reject(new Error(`Non-JSON response (status ${res.statusCode}): ${chunks}`)); }
});
});
req.on("error", reject);
req.write(data);
req.end();
});
}

// A minimal but genuinely QuadWork-seeded CLAUDE.md: carries the signature
// H2 set. (We use the real template rendered for `{{project_name}}`.)
const CLAUDE_TEMPLATE = realReadFileSync(path.join(TEMPLATES_DIR, "CLAUDE.md"), "utf-8");

// A project's OWN hand-authored CLAUDE.md — none of the signature headings.
const FOREIGN_CLAUDE = `# Cool Project — Development Rules

## What is Cool Project

A bespoke tool. Nothing to do with the multi-agent template.

## Tech Stack

- Rust + wgpu

## Git

- Commit format: whatever you like
`;

// ── Pure predicate tests ─────────────────────────────────────────────────
{
const rendered = CLAUDE_TEMPLATE.replace(/\{\{project_name\}\}/g, "demo");
assert.equal(_isSeededClaudeMd(rendered), true, "rendered template is recognized as seeded");
assert.equal(_isSeededClaudeMd(FOREIGN_CLAUDE), false, "hand-authored CLAUDE.md is not seeded");
assert.equal(_isSeededClaudeMd(""), false, "empty content is not seeded");
assert.equal(_isSeededClaudeMd(null), false, "null content is not seeded");
// A seeded file with an operator-added H2 is still recognized.
const withOperator = rendered + "\n## Project Notes\n- run make first\n";
assert.equal(_isSeededClaudeMd(withOperator), true, "operator H2 doesn't hide the signature");
// Missing even one signature heading → not seeded (fail-closed).
const missingOne = rendered.replace(/^## Communication Rules$/m, "## Chatting");
assert.equal(_isSeededClaudeMd(missingOne), false, "missing a signature heading → not seeded");
}

function makeTempProject(label, layout) {
// layout: { agentKey: { cwdName, agentsMd, claudeMd } } — omit a field to
// skip writing that file (simulates a worktree missing it).
const root = fs.mkdtempSync(path.join(os.tmpdir(), `qw-966-${label}-`));
const workingDir = path.join(root, "proj");
fs.mkdirSync(workingDir, { recursive: true });
const agents = {};
for (const [agentKey, spec] of Object.entries(layout)) {
const cwd = path.join(root, spec.cwdName);
fs.mkdirSync(cwd, { recursive: true });
if (spec.agentsMd != null) fs.writeFileSync(path.join(cwd, "AGENTS.md"), spec.agentsMd);
if (spec.claudeMd != null) fs.writeFileSync(path.join(cwd, "CLAUDE.md"), spec.claudeMd);
agents[agentKey] = { cwd };
}
return { workingDir, agents };
}

(async () => {
const dirName = "proj"; // path.basename(workingDir) → what the server substitutes

// ── Endpoint test: seeded refreshed + operator H2 preserved; foreign
// skipped byte-identical; missing handled without error. ──────────────
const projId = `p966-${crypto.randomBytes(4).toString("hex")}`;

// A stale seeded CLAUDE.md (rendered from template) with an operator H2 and
// a deliberately-mangled canonical section that the refresh must overwrite.
const rendered = CLAUDE_TEMPLATE.replace(/\{\{project_name\}\}/g, dirName);
const seededStale = rendered
.replace("QuadWork agent", "STALE-MARKER agent") // canonical body edit
+ "\n## Project Notes\n- Local: run `make dev` before pushing.\n";

const { workingDir, agents } = makeTempProject("mix", {
// head: seeded CLAUDE.md → must be refreshed, operator H2 preserved
head: { cwdName: "proj-head", agentsMd: "# Head\n\nStale.\n", claudeMd: seededStale },
// re1: foreign CLAUDE.md → must be left byte-identical (skipped)
re1: { cwdName: "proj-re1", agentsMd: "# Reviewer 1\n\nStale.\n", claudeMd: FOREIGN_CLAUDE },
// dev: no CLAUDE.md at all → handled without error (skipped)
dev: { cwdName: "proj-dev", agentsMd: "# Dev\n\nStale.\n" },
});

stubCfgJson = JSON.stringify({
projects: [{ id: projId, name: "P966", repo: "ex/p966", working_dir: workingDir, agents }],
});

let resp;
await withServer(async (base) => {
resp = await post(`${base}/api/projects/${projId}/reseed-agents`, { force: true });
});
assert.equal(resp.status, 200, `expected 200, got ${resp.status} ${JSON.stringify(resp.body)}`);
assert.equal(resp.body.ok, true);

// AGENTS.md reseed behavior unchanged: all three still refreshed.
for (const k of ["head", "re1", "dev"]) {
assert.ok(resp.body.reseeded.includes(`${k}/AGENTS.md`), `${k}/AGENTS.md still reseeded`);
}

// head: seeded CLAUDE.md refreshed, stale canonical body gone, operator H2 kept.
const headClaude = realReadFileSync(path.join(agents.head.cwd, "CLAUDE.md"), "utf-8");
assert.ok(resp.body.reseeded.includes("head/CLAUDE.md"), "head/CLAUDE.md reported reseeded");
assert.ok(!headClaude.includes("STALE-MARKER"), "stale canonical body overwritten by fresh template");
assert.ok(headClaude.includes("## Multi-Agent System"), "canonical section present from fresh template");
assert.ok(headClaude.includes("## Project Notes"), "operator H2 heading preserved");
assert.ok(headClaude.includes("run `make dev` before pushing"), "operator H2 body preserved");
assert.deepEqual(resp.body.preserved["head/CLAUDE.md"], ["Project Notes"], "preserved reported under head/CLAUDE.md");

// re1: foreign CLAUDE.md left byte-identical and reported skipped.
const re1Claude = realReadFileSync(path.join(agents.re1.cwd, "CLAUDE.md"), "utf-8");
assert.equal(re1Claude, FOREIGN_CLAUDE, "foreign CLAUDE.md left byte-identical");
assert.ok(resp.body.skipped.includes("re1/CLAUDE.md (not QuadWork-seeded)"), "foreign skip reported");

// dev: no CLAUDE.md → handled without error, reported skipped, none created.
assert.ok(!fs.existsSync(path.join(agents.dev.cwd, "CLAUDE.md")), "no CLAUDE.md created where none existed");
assert.ok(resp.body.skipped.includes("dev/CLAUDE.md (none)"), "missing CLAUDE.md skip reported");

console.log("routes.reseedClaudeMd.test.js: all assertions passed");
})().catch((err) => { console.error(err); process.exit(1); });
Loading