From 7b89a7da752b256c2e7260584aaab8addd57e852 Mon Sep 17 00:00:00 2001 From: noah Date: Sun, 26 Jul 2026 22:41:38 -0400 Subject: [PATCH 1/2] fix(fork): make CLAUDE.md resolve, and make its guard able to fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A symlink's target is its blob verbatim, so the committed ten-byte `AGENTS.md\n` aimed CLAUDE.md at a filename containing a newline. It resolved to nothing: `cat CLAUDE.md` returned No such file or directory. Every agent whose harness reads CLAUDE.md therefore opened this repository with no project instructions at all — including, as it happens, the session that found this. That matters here more than it would elsewhere. The chain CLAUDE.md -> AGENTS.md -> .fork/AGENTS.md is how an agent learns the branch rules and the placement ladder before touching anything, and fork-workflow-docs states in its intent that the symlink delivers exactly that. It did not. Recreate the link with `ln -s`, giving the nine-byte target. The resulting blob is 47dc3e3d8 — byte-identical to the one upstream's own fix 5e13f5357 produced in April, before 6891c77d3 ("Build for Windows ARM") reintroduced the newline twenty-eight commits later, almost certainly via a tool normalizing a trailing newline onto a file nobody meant to edit. The guard is the more important half. It asserted `readlinkSync(...).trim() === "AGENTS.md"`, and `.trim()` strips precisely the byte that constitutes the bug — so the assertion held identically while the link was broken, while it was fixed, and while it was broken again. It never changed value and could not have. That is the same vacuity .fork/notes/FORK-DATA-ISOLATION-HANDOFF.md diagnoses for forkAppIdentity: it tested that an alias was spelled correctly, not that it worked. Drop the trim, and add an assertion for the property actually promised — that reading through CLAUDE.md produces AGENTS.md's contents, fenced fork block included. Verified by reintroducing the broken symlink: both new assertions fail against it and pass against the fix. The old assertion passed against both. Upstream has now shipped this newline twice and fixed it once, so treat it as recurring rather than settled: when a sync brings it back, the guard fails instead of sleeping through it. --- CLAUDE.md | 2 +- .../__fork_guards__/forkWorkflowDocs.test.ts | 23 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c3170642553..47dc3e3d863 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md +AGENTS.md \ No newline at end of file diff --git a/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts b/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts index 773b0234027..c1fe20215c6 100644 --- a/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts +++ b/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts @@ -26,9 +26,24 @@ describe("fork guard: fork-workflow-docs", () => { }); it("keeps CLAUDE.md aliased to AGENTS.md so Claude agents get the same rules", () => { - const link = NodeFS.lstatSync(NodePath.join(repoRoot, "CLAUDE.md")); - expect(link.isSymbolicLink()).toBe(true); - // The committed symlink blob carries a trailing newline in its target. - expect(NodeFS.readlinkSync(NodePath.join(repoRoot, "CLAUDE.md")).trim()).toBe("AGENTS.md"); + const claudePath = NodePath.join(repoRoot, "CLAUDE.md"); + expect(NodeFS.lstatSync(claudePath).isSymbolicLink()).toBe(true); + // No trim. A symlink's target is its blob verbatim, so a trailing newline + // aims it at a filename that cannot exist — which is exactly the state + // this assertion used to normalize away and pass through. Upstream fixed + // that newline in 5e13f5357 and reintroduced it in 6891c77d3 without any + // guard changing value, so match the target exactly. + expect(NodeFS.readlinkSync(claudePath)).toBe("AGENTS.md"); + }); + + it("resolves CLAUDE.md to the rules rather than merely pointing at them", () => { + // The promise in the manifest is that an agent opening CLAUDE.md learns + // the fork rules. That is a statement about what reading it produces, not + // about how the link is spelled, so read through it. + const claude = NodeFS.readFileSync(NodePath.join(repoRoot, "CLAUDE.md"), "utf8"); + const agents = NodeFS.readFileSync(NodePath.join(repoRoot, "AGENTS.md"), "utf8"); + expect(claude).toBe(agents); + expect(claude).toContain("fork:begin fork-workflow"); + expect(claude).toContain(".fork/AGENTS.md"); }); }); From ee636477edc429d0a2331b13fc8232e6c3125002 Mon Sep 17 00:00:00 2001 From: noah Date: Sun, 26 Jul 2026 23:03:03 -0400 Subject: [PATCH 2/2] fix(fork): watch CLAUDE.md and assert the blob that actually ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #18. CLAUDE.md is now a fork-owned Tier-4 inline edit of an upstream root file, and `.fork/AGENTS.md` rule 4 requires those under `watch:`. Without it `detect-drift.mjs` — which fires on `shadows:` + `watch:` only — stays silent when upstream touches the file, so the recurrence channel this PR exists to defend had a guard but no early warning. Verified: a changed-file list of `CLAUDE.md` now reports `fork-workflow-docs`; before, it printed nothing. The intent records that the fork's blob deliberately diverges from upstream's, so a syncer resolving a CLAUDE.md conflict finds the reason in the manifest rather than in a merged PR description. It also states the fence exemption: a symlink's blob is its target verbatim, so a comment marker inside it would break the link it describes. The guard now asserts the committed object instead of the working tree. `git ls-tree` for mode `120000` and `git cat-file` for the exact target are `core.symlinks`-independent — a Windows checkout materializes the blob as a regular file, which would red the working-tree assertions against a correct commit. That false red is the same class of defect as the false green being fixed, and this file's history runs through 6891c77d3 *Build for Windows ARM*. Shelling out to git matches `customizationsManifest.test.ts`, which already does. Read-through stays, since HEAD-level checks cannot see a conflicted or dirty tree, but with an `existsSync` precondition so a broken link fails as an assertion rather than an ENOENT thrown out of `readFileSync`, and with the non-symlink checkout handled explicitly. Dropped the two `toContain` lines that could not fail independently of the `toBe` above them. Mutation-checked all three states: broken link in the tree fails read-through only; broken link committed fails both, the blob one with `expected 'AGENTS.md\n' to be 'AGENTS.md'`; regular-file checkout stays green. Co-Authored-By: Claude Opus 5 (1M context) --- .fork/customizations.yaml | 12 +++++ .../__fork_guards__/forkWorkflowDocs.test.ts | 52 ++++++++++++++----- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index e319ae085ab..9c9fa23841a 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -56,12 +56,24 @@ Fork Workflow section pointing to .fork/AGENTS.md, which defines the branch model (work from custom, never touch main) and the placement ladder for fork changes. + CLAUDE.md is a Tier-4 inline edit the fork owns outright, and it + deliberately diverges from the blob upstream ships. Upstream's committed + symlink target carries a trailing newline, so it names a file that cannot + exist and CLAUDE.md resolves to nothing — upstream fixed that in 5e13f5357 + and reintroduced it in 6891c77d3, so it recurs. The fork's blob is the + 9-byte target with no newline. Resolve any sync conflict on CLAUDE.md by + keeping the fork's side; taking upstream's restores the bug and silently + strips the rules from every Claude agent. It is the one Tier-4 edit that + cannot carry the usual begin/end fence comments: a symlink's blob is its + target verbatim, so a comment marker inside it would break the very link + the marker exists to describe. The watch entry is the whole fence. tier: 4 files: - .fork/AGENTS.md shadows: [] watch: - AGENTS.md + - CLAUDE.md verify: - apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts diff --git a/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts b/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts index c1fe20215c6..49d62574939 100644 --- a/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts +++ b/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts @@ -5,8 +5,13 @@ * AGENTS.md is upstream-owned, so an upstream rewrite could silently drop the * fenced Fork Workflow section in a "clean" sync — and with it, every future * agent's knowledge of the branch rules. Fail loudly instead. + * + * CLAUDE.md is watched for the same reason and owned by the fork: its blob + * deliberately differs from upstream's, which carries a trailing newline that + * breaks the link. Never resolve a sync conflict on it by taking upstream. */ +import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; @@ -26,24 +31,47 @@ describe("fork guard: fork-workflow-docs", () => { }); it("keeps CLAUDE.md aliased to AGENTS.md so Claude agents get the same rules", () => { - const claudePath = NodePath.join(repoRoot, "CLAUDE.md"); - expect(NodeFS.lstatSync(claudePath).isSymbolicLink()).toBe(true); - // No trim. A symlink's target is its blob verbatim, so a trailing newline - // aims it at a filename that cannot exist — which is exactly the state - // this assertion used to normalize away and pass through. Upstream fixed - // that newline in 5e13f5357 and reintroduced it in 6891c77d3 without any - // guard changing value, so match the target exactly. - expect(NodeFS.readlinkSync(claudePath)).toBe("AGENTS.md"); + // Assert the committed object, not the working tree. What an agent gets + // is whatever the clone materializes from this blob, and a checkout with + // core.symlinks=false writes a regular file instead — so a working-tree + // check reds against a perfectly correct commit. The blob is also where + // the bug lives: no trim, because a symlink's target is its blob + // verbatim, and a trailing newline aims it at a filename that cannot + // exist. That is exactly the state the old `.trim()` assertion + // normalized away, holding its value while upstream fixed the newline in + // 5e13f5357 and reintroduced it in 6891c77d3. + const lsTree = NodeChildProcess.execSync("git ls-tree HEAD -- CLAUDE.md", { + cwd: repoRoot, + encoding: "utf8", + }); + expect(lsTree.split(/\s+/u)[0]).toBe("120000"); + const target = NodeChildProcess.execSync("git cat-file -p HEAD:CLAUDE.md", { + cwd: repoRoot, + encoding: "utf8", + }); + expect(target).toBe("AGENTS.md"); }); it("resolves CLAUDE.md to the rules rather than merely pointing at them", () => { // The promise in the manifest is that an agent opening CLAUDE.md learns // the fork rules. That is a statement about what reading it produces, not - // about how the link is spelled, so read through it. - const claude = NodeFS.readFileSync(NodePath.join(repoRoot, "CLAUDE.md"), "utf8"); + // about how the link is spelled, so read through it. This covers what the + // committed-blob check above cannot: a conflicted or dirty working tree, + // where HEAD still holds the fork's good blob. + const claudePath = NodePath.join(repoRoot, "CLAUDE.md"); + if (!NodeFS.lstatSync(claudePath).isSymbolicLink()) { + // core.symlinks=false checkout — git wrote the target as a regular + // file. Read-through is not a property this checkout can have, and the + // commit is still correct, so assert the fallback shape rather than a + // false red. + expect(NodeFS.readFileSync(claudePath, "utf8")).toBe("AGENTS.md"); + return; + } + // Precondition, so a broken link fails as an assertion rather than an + // ENOENT thrown out of readFileSync. + expect(NodeFS.existsSync(claudePath)).toBe(true); + const claude = NodeFS.readFileSync(claudePath, "utf8"); const agents = NodeFS.readFileSync(NodePath.join(repoRoot, "AGENTS.md"), "utf8"); expect(claude).toBe(agents); - expect(claude).toContain("fork:begin fork-workflow"); - expect(claude).toContain(".fork/AGENTS.md"); }); });