fix(render): section refs point at the FINAL render dir, never the tmp swap dir (#2692)

gen-skill-docs bakes its --out-dir into rendered CONTENT (rewriteSectionBase),
and both swap-in callers (setup, gstack-config gbrain-refresh) render into
claude.tmp.<pid> before the #2569 atomic rename — so every rendered skill
carried ~9 dead section Read paths that pointed at a directory the swap had
just deleted. New --link-root flag names the final serving dir (defaults to
--out-dir for direct-render callers: bin/dev-setup, dev-skill.ts, mkdtemp
tests — full caller audit in the wave notes); the rewrite now uses a
replacement callback so a $-bearing configured path can't expand as $& in a
replacement string. The swap logic itself stays byte-identical. Tests pin the
generator contract (tmp out-dir files reference the final dir, $-bearing
path included) and both callers' wiring.

Fixes #2692

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-31 21:23:53 +00:00
co-authored by Claude Fable 5
parent e2d6cb570f
commit 7ed5e87bba
4 changed files with 65 additions and 4 deletions
+21 -2
View File
@@ -168,6 +168,23 @@ const OUT_DIR: string | null = (() => {
return path.resolve(val);
})();
// #2692: callers that render into a TMP dir and atomically swap it into place
// (bin/gstack-config gbrain-refresh, setup — the #2569 pattern) must pass the
// FINAL directory here, or rewriteSectionBase bakes the tmp path
// (…/render/claude.tmp.<pid>/…) into the rendered CONTENT and every section
// Read dies after the swap. Defaults to OUT_DIR for direct-render callers
// (bin/dev-setup, scripts/dev-skill.ts, mkdtemp tests), where out-dir IS the
// serving path.
const LINK_ROOT_ARG = process.argv.find(a => a.startsWith('--link-root'));
const LINK_ROOT: string | null = (() => {
if (!LINK_ROOT_ARG) return OUT_DIR;
const val = LINK_ROOT_ARG.includes('=')
? LINK_ROOT_ARG.split('=')[1]
: process.argv[process.argv.indexOf(LINK_ROOT_ARG) + 1];
if (!val) throw new Error('--link-root requires a directory path');
return path.resolve(val);
})();
/**
* When rendering to an out-dir, repoint the literal section-base path at the
* out-dir so section Reads resolve to the rendered copy, not the global install.
@@ -176,10 +193,12 @@ const OUT_DIR: string | null = (() => {
* install, which still works). No-op when --out-dir is unset.
*/
function rewriteSectionBase(content: string): string {
if (!OUT_DIR) return content;
if (!LINK_ROOT) return content;
// Replacement CALLBACK, not a template string: `$` sequences in a
// configured path are special in JS replacement strings ($&, $', $1…).
return content.replace(
/~\/\.claude\/skills\/gstack\/([^\s)`"'*]+\/sections\/)/g,
`${OUT_DIR}/$1`,
(_m, p1: string) => `${LINK_ROOT}/${p1}`,
);
}