Merge remote-tracking branch 'origin/main' into garrytan/codex-review-default-on

This commit is contained in:
Garry Tan
2026-06-09 22:29:49 -07:00
27 changed files with 1141 additions and 47 deletions
+18
View File
@@ -50,6 +50,24 @@ function regenerateAndValidate() {
console.log(` [check] \u2705 ${output}${totalValid} commands, all valid`);
}
}
// Dev workspace render isolation: the default in-place regen above keeps the
// worktree canonical. If bin/dev-setup set up an untracked brain-aware render
// (.claude/gstack-rendered), refresh it too so live template edits reflect at
// this workspace's runtime. Only runs when the render dir already exists — we
// never create it during plain template dev.
const RENDER_DIR = path.join(ROOT, '.claude', 'gstack-rendered');
if (fs.existsSync(RENDER_DIR)) {
try {
execSync(
`bun run scripts/gen-skill-docs.ts --respect-detection --host claude --out-dir ${JSON.stringify(RENDER_DIR)}`,
{ cwd: ROOT, stdio: 'pipe' },
);
console.log(' [render] refreshed .claude/gstack-rendered (brain-aware workspace copy)');
} catch (err: any) {
console.log(` [render] ERROR: ${err.stderr?.toString().trim() || err.message}`);
}
}
}
// Initial run
+55 -4
View File
@@ -137,6 +137,39 @@ const EXPLAIN_LEVEL: 'default' | 'terse' = (() => {
return val;
})();
// ─── 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.
const OUT_DIR_ARG = process.argv.find(a => a.startsWith('--out-dir'));
const OUT_DIR: string | null = (() => {
if (!OUT_DIR_ARG) return null;
const val = OUT_DIR_ARG.includes('=')
? OUT_DIR_ARG.split('=')[1]
: process.argv[process.argv.indexOf(OUT_DIR_ARG) + 1];
if (!val) throw new Error('--out-dir 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.
* Surgical: ONLY paths containing `/sections/` are rewritten — bin/, browse/,
* docs/ references keep pointing at `~/.claude/skills/gstack` (the global
* install, which still works). No-op when --out-dir is unset.
*/
function rewriteSectionBase(content: string): string {
if (!OUT_DIR) return content;
return content.replace(
/~\/\.claude\/skills\/gstack\/([^\s)`"'*]+\/sections\/)/g,
`${OUT_DIR}/$1`,
);
}
// HostPaths, HOST_PATHS, and TemplateContext imported from ./resolvers/types (line 7-8)
// Design constants (AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS)
// live in ./resolvers/constants and are consumed by resolvers directly.
@@ -768,6 +801,12 @@ 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.
if (OUT_DIR && host === 'claude') {
outputPath = path.join(OUT_DIR, skillDir, path.basename(tmplPath).replace(/\.tmpl$/, ''));
}
// Extract name/description: name drives external skill naming + setup symlinks
// (and TemplateContext.skillName via buildContext); description feeds external
// host metadata. When frontmatter name: differs from directory name (e.g.
@@ -822,6 +861,9 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath:
}
}
// --out-dir: repoint section-base paths to the out-dir (no-op otherwise).
if (host === 'claude') content = rewriteSectionBase(content);
return { outputPath, content, symlinkLoop, catalogParts };
}
@@ -860,6 +902,10 @@ function processSectionTemplate(
// External hosts: rewrite cross-reference paths/tools (no frontmatter to transform).
if (host !== 'claude') {
content = applyHostRewrites(content, hostConfig);
} else {
// --out-dir: a section may cross-reference another section by absolute path;
// repoint those to the out-dir too (no-op when --out-dir is unset).
content = rewriteSectionBase(content);
}
// Plain generated header (no frontmatter to insert after).
@@ -868,7 +914,7 @@ function processSectionTemplate(
const fileName = path.basename(sectionTmplPath).replace(/\.tmpl$/, '');
let outputPath: string;
if (host === 'claude') {
outputPath = path.join(ROOT, skillDir, 'sections', fileName);
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);
@@ -933,7 +979,7 @@ for (const currentHost of hostsToRun) {
voice_line: catalogParts.voiceLine,
};
}
const relOutput = path.relative(ROOT, outputPath);
const relOutput = path.relative(OUT_DIR || ROOT, outputPath);
if (symlinkLoop) {
console.log(`SKIPPED (symlink loop): ${relOutput}`);
@@ -946,6 +992,9 @@ for (const currentHost of hostsToRun) {
console.log(`FRESH: ${relOutput}`);
}
} else {
// In-place writes land in existing dirs; --out-dir needs the mirrored
// skill dir created first.
if (OUT_DIR) fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, content);
console.log(`GENERATED: ${relOutput}`);
}
@@ -982,7 +1031,7 @@ for (const currentHost of hostsToRun) {
currentHostConfig.generation.skipSkills.includes(sec.skillDir)) continue;
const { outputPath, content } = processSectionTemplate(path.join(ROOT, sec.tmpl), sec.skillDir, currentHost);
const relOutput = path.relative(ROOT, outputPath);
const relOutput = path.relative(OUT_DIR || ROOT, outputPath);
if (DRY_RUN) {
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
@@ -1079,7 +1128,9 @@ The orchestrator will persist the plan link to its own memory/knowledge store.
// No timestamp field — keeps the file content-deterministic across runs so
// CI dry-run freshness checks don't flap on regen. If a per-run timestamp
// is ever needed for debugging, write it to a separate `.gen-stamp` file.
if (currentHost === 'claude' && CATALOG_MODE === 'trim' && Object.keys(proactiveAggregate).length > 0 && !DRY_RUN) {
// Skip the global proactive-suggestions.json in --out-dir mode: it lives at
// a repo path (scripts/) and the dev workspace render doesn't need it.
if (currentHost === 'claude' && CATALOG_MODE === 'trim' && Object.keys(proactiveAggregate).length > 0 && !DRY_RUN && !OUT_DIR) {
const proactivePath = path.join(ROOT, 'scripts', 'proactive-suggestions.json');
// Sort keys alphabetically so the serialized JSON is identical across
// machines regardless of filesystem-iteration order. Without this, CI