feat(gen): --out-dir renders every host, outputs-only

--out-dir was Claude-host-only (gen-skill-docs.ts:842), which forced
the codex/factory-regenerating tests (gen-skill-docs, skill-validation,
host-config) to mutate the live tree — the reason they sit in the
TREE_MUTATING serial shard. The flag now mirrors ALL outputs into the
out-dir: external-host trees (.agents/.factory/... via
processExternalHost), external section files, openclaw docs, and
gstack/llms.txt (a catalog-mode render must never rewrite the tracked
index). OUTPUTS ONLY — inputs (templates, sections/, host configs) are
always read from ROOT, so an empty out-dir can never feed the render.
rewriteSectionBase stays Claude-only (external hosts have their own
path grammar).

Proofs: in-place --host all is byte-identical (tree clean);
--host all --out-dir <mkdtemp> renders the full multi-host tree with
ROOT untouched; gen-skill-docs-out-dir tests + 415/415
gen-skill-docs.test.ts green (bin/dev-setup's claude rendering
byte-compat).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-29 05:03:13 +00:00
co-authored by Claude Fable 5
parent 57501f5d6f
commit 95b779f7cd
+28 -15
View File
@@ -146,13 +146,18 @@ const EXPLAIN_LEVEL: 'default' | 'terse' = (() => {
})();
// ─── Out-dir (dev workspace render isolation) ───────────────
// --out-dir <abs-dir> redirects Claude SKILL.md + section output to a separate
// (untracked) directory instead of writing in place, AND rewrites the literal
// section-base path (`~/.claude/skills/gstack/<skill>/sections/`) inside the
// generated content to point at the out-dir, so section Reads resolve to the
// rendered copy rather than the global install. Used by bin/dev-setup to render
// the gbrain `:user` variant for a Conductor workspace without dirtying tracked
// source. Default (unset) = in-place, behavior unchanged. Claude host only.
// --out-dir <abs-dir> redirects ALL generated output (Claude SKILL.md +
// sections, external-host trees like .agents/.factory, openclaw docs,
// gstack/llms.txt) into a separate (untracked) directory instead of writing
// in place. OUTPUTS ONLY: inputs (templates, sections/, host configs) are
// always read from ROOT. For the Claude host it ALSO rewrites the literal
// section-base path (`~/.claude/skills/gstack/<skill>/sections/`) inside
// generated content so section Reads resolve to the rendered copy — that
// rewrite stays Claude-only (external hosts have their own path grammar).
// Consumers: bin/dev-setup (renders the gbrain `:user` variant for a
// Conductor workspace — byte-compat pinned by gen-skill-docs-out-dir tests)
// and the former TREE_MUTATING tests, which render into a mkdtemp instead
// of mutating the live tree. Default (unset) = in-place, unchanged.
const OUT_DIR_ARG = process.argv.find(a => a.startsWith('--out-dir'));
const OUT_DIR: string | null = (() => {
if (!OUT_DIR_ARG) return null;
@@ -778,7 +783,8 @@ function processExternalHost(
const hostConfig = getHostConfig(host);
const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
const outputDir = path.join(ROOT, hostConfig.hostSubdir, 'skills', name);
// --out-dir mirrors the host tree (outputs only; inputs read from ROOT).
const outputDir = path.join(OUT_DIR ?? ROOT, hostConfig.hostSubdir, 'skills', name);
fs.mkdirSync(outputDir, { recursive: true });
const outputPath = path.join(outputDir, 'SKILL.md');
@@ -837,8 +843,8 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath:
// Determine skill directory relative to ROOT
const skillDir = path.relative(ROOT, path.dirname(tmplPath));
// --out-dir (Claude only): mirror the skill tree into the out-dir instead of
// writing in place. External hosts compute their own paths below.
// --out-dir: mirror the skill tree into the out-dir instead of writing in
// place (external hosts compute their own OUT_DIR-aware paths below).
if (OUT_DIR && host === 'claude') {
outputPath = path.join(OUT_DIR, skillDir, path.basename(tmplPath).replace(/\.tmpl$/, ''));
}
@@ -949,7 +955,7 @@ function processSectionTemplate(
outputPath = path.join(OUT_DIR || ROOT, skillDir, 'sections', fileName);
} else {
const externalName = externalSkillName(skillDir, parentName);
outputPath = path.join(ROOT, hostConfig.hostSubdir, 'skills', externalName, 'sections', fileName);
outputPath = path.join(OUT_DIR ?? ROOT, hostConfig.hostSubdir, 'skills', externalName, 'sections', fileName);
}
if (!DRY_RUN) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
return { outputPath, content };
@@ -1063,6 +1069,7 @@ for (const currentHost of hostsToRun) {
console.log(`FRESH: ${relOutput}`);
}
} else {
if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, content);
console.log(`GENERATED: ${relOutput}`);
}
@@ -1079,12 +1086,14 @@ for (const currentHost of hostsToRun) {
// plain markdown, no placeholder resolution — and are copied byte-for-byte
// to openclaw/ at gen time.
if (currentHost === 'openclaw' && !DRY_RUN) {
const openclawDir = path.join(ROOT, 'openclaw');
const openclawTemplatesDir = path.join(openclawDir, 'templates');
// Inputs from ROOT, outputs into OUT_DIR when set (outputs-only rule).
const openclawTemplatesDir = path.join(ROOT, 'openclaw', 'templates');
const openclawOutDir = path.join(OUT_DIR ?? ROOT, 'openclaw');
if (OUT_DIR) fs.mkdirSync(openclawOutDir, { recursive: true });
for (const variant of ['lite', 'full', 'plan'] as const) {
const fileName = `gstack-${variant}-CLAUDE.md`;
const content = fs.readFileSync(path.join(openclawTemplatesDir, fileName), 'utf-8');
fs.writeFileSync(path.join(openclawDir, fileName), content);
fs.writeFileSync(path.join(openclawOutDir, fileName), content);
console.log(`GENERATED: openclaw/${fileName}`);
}
}
@@ -1152,7 +1161,11 @@ if (!DRY_RUN) {
if (!DRY_RUN) {
void (async () => {
try {
const result = await writeLlmsTxt();
const result = await writeLlmsTxt(
// Outputs-only rule: under --out-dir even this index lands there
// (a catalog-mode render must never rewrite the tracked llms.txt).
OUT_DIR ? { outputPath: path.join(OUT_DIR, 'gstack', 'llms.txt') } : {},
);
if (result.warnings.length > 0) {
for (const w of result.warnings) console.error(`[gen-llms-txt] WARN: ${w}`);
} else {