
The Wrong Posts Directory: Why the Hub Tree Never Reaches withagents.dev
I wrote the next field-journal entry into the hub posts/ tree and the live Next.js site never saw it. The build reads only site/posts/. The parent tree is not a build input.
View companion repoFifty files in the hub and a site that still ended at forty-five
I opened the WithAgents forge on 2026-08-04 to inventory the field journal before the next ship. The question was not "is the prose done." The question was "which tree is live." The session already had the answer written down: the hub held fifty posts, the site held forty-five typed post-bodies, and those two counts were not a rounding error. They were two different directories pretending to be one catalog.
The muscle memory is almost impossible to unlearn. I author in blog-series/posts/<slug>/post.md. That is where the social adapters live, where the hero HTML lives, where every editorial gate I have written expects to find a slug. When I finish a draft, the file is real. ls shows it. git status shows it. A word-count script against the hub tree reports the new post. None of that is a lie. It is also not a ship.
The live site is not the parent repo. The live site is a Next.js project checked in as a git submodule under blog-series/site/. Its working directory at build time is site/. Its posts directory is site/posts/. If the new slug is missing from that second tree, withagents.dev does not have a 500 and does not print a warning. The index is whatever getAllPosts() can readdir. The missing post is simply not in the list.
That is the whole defect. Not a cache. Not a CDN. Not a typed-body override. The write landed in the canonical pipeline tree, and the renderer was never pointed at that tree.
The comment that is the whole architecture
I stopped arguing with my own habit and opened the file the build actually imports. site/src/lib/posts.ts does not hide the contract. Lines 6 through 15 are a comment plus one assignment, and the assignment is the architecture:
// The site is a Next.js project that owns its own posts/ dir (checked into
// the site submodule). The build reads exclusively from this local dir; the
// parent blog-series repo's posts/ is NOT a build input. Referencing
// `path.join(process.cwd(), "..", "posts")` here would let the bundler's
// file tracer include the parent dir (and its 200+MB of .mimirs DBs,
// audit archives, etc.) in the serverless function's nft manifest,
// tripping Vercel's 250MB per-function limit. The dual-fallback was a
// legacy convenience for fresh clones; the site ships with posts/
// already populated, so the canonical path is the local one.
const postsDirectory = path.join(process.cwd(), "posts");
process.cwd() during next build on Vercel is the site package root. posts therefore means site/posts, not blog-series/posts. There is no ... There is no "try the parent if the local dir is empty." There used to be a dual-fallback for fresh clones. It is gone on purpose.
getAllPosts() then does the only thing a local directory contract can do. It readdirSyncs that one path, keeps directories whose names start with post-, and reads post.md inside each. If the file is absent, the entry becomes null and drops out of the list. The function cannot see a sibling repo. It cannot see a hub tree one directory up. It cannot invent a slug that is not on disk next to the Next.js app.
export function getAllPosts(): Post[] {
const entries = fs.readdirSync(postsDirectory, { withFileTypes: true });
const posts = entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith("post-"))
.map((entry) => {
const slug = entry.name;
const postPath = path.join(postsDirectory, slug, "post.md");
if (!fs.existsSync(postPath)) return null;
// ...
})
.filter((post): post is Post => post !== null);
A writer who only touches the hub has produced a perfectly valid artifact for the pipeline and a no-op for the site. The site is not being stubborn. The site is doing exactly what that comment says.
Why a parent-dir fallback would have blown the function
The dual-fallback looks like the obvious fix the first time this bites. Point postsDirectory at path.join(process.cwd(), "..", "posts") when the local dir is empty, or worse, always prefer the parent because "that is the source of truth." I have typed that join in other projects. It is the wrong join here.
Next.js traces files that a serverless function can reach. A static reference to ../posts is not a runtime lookup that stays on the build machine. It is a hint to the bundler's file tracer: this function may read that tree, so pack the tree into the nft manifest. The parent blog-series/posts/ tree is not a clean markdown folder. It carries .mimirs databases, audit archives, rendered social assets, and enough adjacent junk that the comment on posts.ts puts the weight at 200+MB. Vercel's per-function limit is 250MB. The fallback does not fail the page. It fails the deploy, or it ships a function that is one archive away from the cap.
That is why the local path is not a style preference. The site submodule is supposed to ship already populated. A fresh clone that forgot to copy posts into site/posts/ should look empty, loudly, in a local next dev. It should not reach into the parent and pull two hundred megabytes of operator state into a serverless zip because someone wanted convenience on day one.
The dual-fallback was a legacy convenience for fresh clones. Convenience that can trip a hard platform limit is not convenience. It is a landmine with a friendly name.
The forge session that already named the split
I did not discover this split while writing this post. The 2026-08-04 forge session already named it, in the same inventory pass that counted the catalog. Hub: fifty posts. Site: forty-five post-bodies. The five-slot gap was not "five drafts still in review." It was the measurable distance between the tree I edit by habit and the tree the renderer is allowed to see.
The session was doing what forge sessions do: walk the real repo, refuse memory-only claims, write the counts down. The useful part was not the numbers. The useful part was the refusal to treat those numbers as the same variable. "Fifty posts" is a statement about blog-series/posts/. "Forty-five post-bodies" is a statement about site/src/lib/post-bodies/. Even that second count is a third tree, the typed Block[] registry that stops at post-43 and leaves later slugs on the markdown fallback. Three representations. Two directories that look like posts/. One live URL.
Once the split is named, every later "I shipped post N" claim has to say which directory received the file. Hub-only is a pipeline artifact. Site-only is a live page with no social adapters and no hero source. Dual write is the only claim that means "this slug can appear on withagents.dev and still have a Pulse article to stand behind it."
I have watched agents, including ones I spawned, finish a beautiful hub draft and mark the task done. The file is there. The word count is inside the envelope. The LinkedIn article has a Body section. The live index does not move. The agent is not lying about the write. The agent is lying about the audience.
Adjacent to the silent no-op, not the same bug
Post 45 is the cousin of this failure, and it is easy to mash the two together. In that writeup I edited post.md, watched the deploy go green, and reloaded a page that still showed the old paragraph. The write had worked. The renderer had ignored it. The reason was a typed Block[] body registered in site/src/lib/post-bodies/. getPostBody(slug) wins. markdownToHtml(post.content) never runs. Forty-three slugs live on that path. The markdown file is still the editorial source. It is not the rendered body.
This post is the other tree.
Post 45 is one directory, two representations: site/posts/<slug>/post.md versus site/src/lib/post-bodies/post-NN.ts. The file the CMS wrote is on the site. The function that paints the page prefers a different file in the same package.
Post 52 is two directories that share a name: blog-series/posts/<slug>/post.md versus blog-series/site/posts/<slug>/post.md. The file the pipeline wrote is not on the site at all. getAllPosts() never opens it. There is no typed-body override to blame. There is no if (typedBody) to flip. The slug is absent from the readdir.
Collapsing the two bugs into "the CMS writes the wrong file" produces the wrong fix. A staleness check between post.md and post-NN.ts does not help a slug that never entered site/posts/. Copying a hub file into a typed body does not help either, and for posts 51 through 55 it is forbidden: typed bodies stop at post-43, and these slugs are supposed to ride the markdown fallback. The correct write is boring. Put the same post.md in both trees.
The write path that never reaches the read path
Once I accepted the comment as law, the failure mode got simpler, which is usually a sign the diagnosis is right.
The hub write path is rich. It wants post.md, four social files, and a hero-card HTML source at 3600x1881. Editorial gates lint that tree. Word count runs against that tree. A human reviewing a PR looks at that tree first because that is where the series has always lived.
The site read path is poor on purpose. It wants one markdown file per slug, gray-matter frontmatter, and a directory name that starts with post-. It does not want the parent repo. It does not want .mimirs. It does not want a fallback that makes a fresh clone look populated when the submodule is empty.
Those two paths only meet if a person or an agent copies the file. There is no hook that does it. There is no next.config alias. There is no fs.symlinkSync in posts.ts. The comment is explicit that adding one would be a bundler event, not a convenience.
So the silent outcome is structural. Hub write succeeds. Site readdir does not include the slug. getPostBySlug returns null. The route is a 404 or an index miss, depending on how the page is reached. No build log line says "you forgot the other tree." The build cannot know a file exists in a directory it is forbidden to trace.
This is why "the deploy was green" is not evidence the post is live. A green deploy of the site submodule proves the site compiled the files it already had. It does not prove the hub gained a twin.
What I now check before I call a post shipped
The check is ugly and it is the only one I trust.
First, the hub path exists: /Users/nick/dev/blog-series/posts/<slug>/post.md. That is necessary for the pipeline. It is not sufficient for the site.
Second, the site path exists: /Users/nick/dev/blog-series/site/posts/<slug>/post.md. That is the live build input. posts.ts line 15 is the citation for that sentence.
Third, I do not "fix" a missing site file by restoring the parent-dir fallback. If the site tree is empty, the correct move is to copy the markdown in, not to teach the bundler about ../posts.
Fourth, I do not update series_total on posts 01 through 50 to make the new slug feel official. The live index does not need the old files rewritten. It needs the new file in the directory it already reads.
Fifth, I do not add a typed body under site/src/lib/post-bodies/ for a post after 43. That would re-enter the post-45 trap on a slug that is supposed to render from markdown. Dual write of post.md is the whole ship.
The writer contract for this batch says the same thing in fewer words: each slug must land in both trees, and the site copy is the one withagents.dev will compile. I am following that contract in the same breath I am writing about why it exists. The irony is load-bearing. A post about the wrong directory that only existed in the hub directory would be a joke I have already told.
The durable rule
Any time two directories share a basename and only one of them is on the runtime's cwd, the other directory is a staging area, no matter how canonical it feels. posts/ in the parent repo is canonical for people and for the publishing pipeline. posts/ in the site submodule is canonical for Next.js. Those are not the same sentence.
The generalization is not "never have two trees." Two trees is how a submodule stays deployable without vacuuming operator state into Vercel. The generalization is: name the read path in the same comment as the write path, and treat a write that cannot reach that read path as unfinished work. A green ls on the staging tree is a local comfort. A green readdir on path.join(process.cwd(), "posts") is the page.
I keep the 2026-08-04 session id next to the posts.ts citation because one is the day I counted the drift and the other is the file that made the drift legal. Hub fifty. Site forty-five typed bodies. Parent posts/ is not a build input. Dual-fallback would pack 200MB into a 250MB function. The next post I write still starts in the hub, because that is where the social files belong. It is not shipped until the same markdown exists under site/posts/, which is the only posts/ the live app is allowed to see.
Continue the series
- 51SeriesZero Behavior Change: A Structure-Only Refactor Is a Claim Until Someone Can Falsify ItIndependent reviewers on yt-transition-shorts-detector were told ZERO behavior change for deepen phases. Iron rule: cite evidence or FAIL. A refactor is not proven by the author saying it is structure-only.
- 53SeriesThe Unwired Specialist: When Delegation Fails Because the Model Was Never ThereFive recon specialists died in under a second with the same missing-route error. No active credentials for provider: anthropic. That is a missing model role, not a rate limit.
- 50SeriesThe Negative Control: Why a Test Suite of Only Positive Matches Cannot Catch Over-MatchingMy invoice verifier passed every check on two real PDFs, then passed a stale-bank check on a Chase-era invoice that still carried the old account numbers. Every assertion was a positive match. None of them asked the pattern to fail.
- 54SeriesDrive Don't Sweep: A Green HTTP Status Is Not Proof a Screen WorksA cookie-less HTTP sweep called every Forge route clean. Every response was the same login page. Status OK measured the server. It never measured the screen.