merge: resolve CHANGELOG conflict with main, bump to v0.13.6.0

Main landed v0.13.5.0 (Factory Droid Compatibility) while this branch
already had v0.13.5.0 (GStack Learns). Bumped to v0.13.6.0 and kept
both entries in chronological order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-29 13:19:57 -07:00
51 changed files with 23722 additions and 136 deletions
+205 -121
View File
@@ -17,7 +17,7 @@ import * as path from 'path';
import type { Host, TemplateContext } from './resolvers/types';
import { HOST_PATHS } from './resolvers/types';
import { RESOLVERS } from './resolvers/index';
import { codexSkillName, transformFrontmatter, extractHookSafetyProse, extractNameAndDescription, condenseOpenAIShortDescription, generateOpenAIYaml } from './resolvers/codex-helpers';
import { externalSkillName, extractHookSafetyProse as _extractHookSafetyProse, extractNameAndDescription as _extractNameAndDescription, condenseOpenAIShortDescription as _condenseOpenAIShortDescription, generateOpenAIYaml as _generateOpenAIYaml } from './resolvers/codex-helpers';
import { generatePlanCompletionAuditShip, generatePlanCompletionAuditReview, generatePlanVerificationExec } from './resolvers/review';
const ROOT = path.resolve(import.meta.dir, '..');
@@ -26,14 +26,20 @@ const DRY_RUN = process.argv.includes('--dry-run');
// ─── Host Detection ─────────────────────────────────────────
const HOST_ARG = process.argv.find(a => a.startsWith('--host'));
const HOST: Host = (() => {
type HostArg = Host | 'all';
const HOST_ARG_VAL: HostArg = (() => {
if (!HOST_ARG) return 'claude';
const val = HOST_ARG.includes('=') ? HOST_ARG.split('=')[1] : process.argv[process.argv.indexOf(HOST_ARG) + 1];
if (val === 'codex' || val === 'agents') return 'codex';
if (val === 'factory' || val === 'droid') return 'factory';
if (val === 'claude') return 'claude';
throw new Error(`Unknown host: ${val}. Use claude, codex, or agents.`);
if (val === 'all') return 'all';
throw new Error(`Unknown host: ${val}. Use claude, codex, factory, droid, agents, or all.`);
})();
// For single-host mode, HOST is the host. For --host all, it's set per iteration below.
let HOST: Host = HOST_ARG_VAL === 'all' ? 'claude' : HOST_ARG_VAL;
// HostPaths, HOST_PATHS, and TemplateContext imported from ./resolvers/types (line 7-8)
// ─── Shared Design Constants ────────────────────────────────
@@ -74,9 +80,10 @@ const OPENAI_LITMUS_CHECKS = [
'Would design feel premium with all decorative shadows removed?',
];
// ─── Codex Helpers ───────────────────────────────────────────
// ─── External Host Helpers ───────────────────────────────────
function codexSkillName(skillDir: string): string {
// Re-export local copy for use in this file (matches codex-helpers.ts)
function externalSkillName(skillDir: string): string {
if (skillDir === '.' || skillDir === '') return 'gstack';
// Don't double-prefix: gstack-upgrade → gstack-upgrade (not gstack-gstack-upgrade)
if (skillDir.startsWith('gstack-')) return skillDir;
@@ -145,33 +152,48 @@ policy:
}
/**
* Transform frontmatter for Codex: keep only name + description.
* Strips allowed-tools, hooks, version, and all other fields.
* Handles multiline block scalar descriptions (YAML | syntax).
* Transform frontmatter for external hosts.
* Claude: strips `sensitive:` field (only Factory uses it).
* Codex: keeps name + description only, enforces 1024-char limit.
* Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation.
*/
function transformFrontmatter(content: string, host: Host): string {
if (host === 'claude') return content;
if (host === 'claude') {
// Strip sensitive: field from Claude output (only Factory uses it)
return content.replace(/^sensitive:\s*true\n/m, '');
}
const fmStart = content.indexOf('---\n');
if (fmStart !== 0) return content;
const fmEnd = content.indexOf('\n---', fmStart + 4);
if (fmEnd === -1) return content;
const frontmatter = content.slice(fmStart + 4, fmEnd);
const body = content.slice(fmEnd + 4); // includes the leading \n after ---
const { name, description } = extractNameAndDescription(content);
// Codex 1024-char description limit — fail build, don't ship broken skills
const MAX_DESC = 1024;
if (description.length > MAX_DESC) {
throw new Error(
`Codex description for "${name}" is ${description.length} chars (max ${MAX_DESC}). ` +
`Compress the description in the .tmpl file.`
);
if (host === 'codex') {
// Codex 1024-char description limit — fail build, don't ship broken skills
const MAX_DESC = 1024;
if (description.length > MAX_DESC) {
throw new Error(
`Codex description for "${name}" is ${description.length} chars (max ${MAX_DESC}). ` +
`Compress the description in the .tmpl file.`
);
}
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
return `---\nname: ${name}\ndescription: |\n${indentedDesc}\n---` + body;
}
// Re-emit Codex frontmatter (name + description only)
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
const codexFm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\n---`;
return codexFm + body;
if (host === 'factory') {
const sensitive = /^sensitive:\s*true/m.test(frontmatter);
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
let fm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\nuser-invocable: true\n`;
if (sensitive) fm += `disable-model-invocation: true\n`;
fm += '---';
return fm + body;
}
return content; // unknown host: passthrough
}
/**
@@ -205,10 +227,95 @@ function extractHookSafetyProse(tmplContent: string): string | null {
return `> **Safety Advisory:** This skill includes safety checks that ${safetyChecks}. When using this skill, always pause and verify before executing potentially destructive operations. If uncertain about a command's safety, ask the user for confirmation before proceeding.`;
}
// ─── External Host Config ────────────────────────────────────
interface ExternalHostConfig {
hostSubdir: string; // '.agents' | '.factory'
generateMetadata: boolean; // true for codex (openai.yaml), false for factory
descriptionLimit?: number; // 1024 for codex, undefined for factory
}
const EXTERNAL_HOST_CONFIG: Record<string, ExternalHostConfig> = {
codex: { hostSubdir: '.agents', generateMetadata: true, descriptionLimit: 1024 },
factory: { hostSubdir: '.factory', generateMetadata: false },
};
// ─── Template Processing ────────────────────────────────────
const GENERATED_HEADER = `<!-- AUTO-GENERATED from {{SOURCE}} — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n`;
/**
* Process external host output: routing, frontmatter, path rewrites, metadata.
* Shared between Codex and Factory (and future external hosts).
*/
function processExternalHost(
content: string,
tmplContent: string,
host: Host,
skillDir: string,
extractedDescription: string,
ctx: TemplateContext,
): { content: string; outputPath: string; outputDir: string; symlinkLoop: boolean } {
const config = EXTERNAL_HOST_CONFIG[host];
if (!config) throw new Error(`No external host config for: ${host}`);
const name = externalSkillName(skillDir === '.' ? '' : skillDir);
const outputDir = path.join(ROOT, config.hostSubdir, 'skills', name);
fs.mkdirSync(outputDir, { recursive: true });
const outputPath = path.join(outputDir, 'SKILL.md');
// Guard against symlink loops
let symlinkLoop = false;
const claudePath = ctx.tmplPath.replace(/\.tmpl$/, '');
try {
const resolvedClaude = fs.realpathSync(claudePath);
const resolvedExternal = fs.realpathSync(path.dirname(outputPath)) + '/' + path.basename(outputPath);
if (resolvedClaude === resolvedExternal) {
symlinkLoop = true;
}
} catch {
// realpathSync fails if file doesn't exist yet — no symlink loop
}
// Extract hook safety prose BEFORE transforming frontmatter (which strips hooks)
const safetyProse = extractHookSafetyProse(tmplContent);
// Transform frontmatter (host-aware)
let result = transformFrontmatter(content, host);
// Insert safety advisory at the top of the body (after frontmatter)
if (safetyProse) {
const bodyStart = result.indexOf('\n---') + 4;
result = result.slice(0, bodyStart) + '\n' + safetyProse + '\n' + result.slice(bodyStart);
}
// Replace hardcoded Claude paths with host-appropriate paths
result = result.replace(/~\/\.claude\/skills\/gstack/g, ctx.paths.skillRoot);
result = result.replace(/\.claude\/skills\/gstack/g, ctx.paths.localSkillRoot);
result = result.replace(/\.claude\/skills\/review/g, `${config.hostSubdir}/skills/gstack/review`);
result = result.replace(/\.claude\/skills/g, `${config.hostSubdir}/skills`);
// Factory-only: translate Claude Code tool names to generic phrasing
if (host === 'factory') {
result = result.replace(/use the Bash tool/g, 'run this command');
result = result.replace(/use the Write tool/g, 'create this file');
result = result.replace(/use the Read tool/g, 'read the file');
result = result.replace(/use the Agent tool/g, 'dispatch a subagent');
result = result.replace(/use the Grep tool/g, 'search for');
result = result.replace(/use the Glob tool/g, 'find files matching');
}
// Codex-only: generate openai.yaml metadata
if (config.generateMetadata && !symlinkLoop) {
const agentsDir = path.join(outputDir, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
const shortDescription = condenseOpenAIShortDescription(extractedDescription);
fs.writeFileSync(path.join(agentsDir, 'openai.yaml'), generateOpenAIYaml(name, shortDescription));
}
return { content: result, outputPath, outputDir, symlinkLoop };
}
function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: string; content: string; symlinkLoop?: boolean } {
const tmplContent = fs.readFileSync(tmplPath, 'utf-8');
const relTmplPath = path.relative(ROOT, tmplPath);
@@ -217,32 +324,6 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath:
// Determine skill directory relative to ROOT
const skillDir = path.relative(ROOT, path.dirname(tmplPath));
let outputDir: string | null = null;
// For codex host, route output to .agents/skills/{codexSkillName}/SKILL.md
let symlinkLoop = false;
if (host === 'codex') {
const codexName = codexSkillName(skillDir === '.' ? '' : skillDir);
outputDir = path.join(ROOT, '.agents', 'skills', codexName);
fs.mkdirSync(outputDir, { recursive: true });
outputPath = path.join(outputDir, 'SKILL.md');
// Guard against symlink loops: if .agents/skills/gstack → repo root,
// writing to .agents/skills/gstack/SKILL.md would overwrite the Claude version.
// Skip the write entirely for this skill — the codex content is still generated
// for token budget tracking.
const claudePath = tmplPath.replace(/\.tmpl$/, '');
try {
const resolvedClaude = fs.realpathSync(claudePath);
const resolvedCodex = fs.realpathSync(path.dirname(outputPath)) + '/' + path.basename(outputPath);
if (resolvedClaude === resolvedCodex) {
symlinkLoop = true;
}
} catch {
// realpathSync fails if file doesn't exist yet — that's fine, no symlink loop
}
}
// Extract skill name from frontmatter for TemplateContext
const { name: extractedName, description: extractedDescription } = extractNameAndDescription(tmplContent);
const skillName = extractedName || path.basename(path.dirname(tmplPath));
@@ -272,34 +353,16 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath:
throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`);
}
// For codex host: transform frontmatter and replace Claude-specific paths
if (host === 'codex') {
// Extract hook safety prose BEFORE transforming frontmatter (which strips hooks)
const safetyProse = extractHookSafetyProse(tmplContent);
// Transform frontmatter: keep only name + description
// For Claude: strip sensitive: field (only Factory uses it)
// For external hosts: route output, transform frontmatter, rewrite paths
let symlinkLoop = false;
if (host === 'claude') {
content = transformFrontmatter(content, host);
// Insert safety advisory at the top of the body (after frontmatter)
if (safetyProse) {
const bodyStart = content.indexOf('\n---') + 4;
content = content.slice(0, bodyStart) + '\n' + safetyProse + '\n' + content.slice(bodyStart);
}
// Replace remaining hardcoded Claude paths with host-appropriate paths
content = content.replace(/~\/\.claude\/skills\/gstack/g, ctx.paths.skillRoot);
content = content.replace(/\.claude\/skills\/gstack/g, ctx.paths.localSkillRoot);
content = content.replace(/\.claude\/skills\/review/g, '.agents/skills/gstack/review');
content = content.replace(/\.claude\/skills/g, '.agents/skills');
if (outputDir && !symlinkLoop) {
const codexName = codexSkillName(skillDir === '.' ? '' : skillDir);
const agentsDir = path.join(outputDir, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
const displayName = codexName;
const shortDescription = condenseOpenAIShortDescription(extractedDescription);
fs.writeFileSync(path.join(agentsDir, 'openai.yaml'), generateOpenAIYaml(displayName, shortDescription));
}
} else {
const result = processExternalHost(content, tmplContent, host, skillDir, extractedDescription, ctx);
content = result.content;
outputPath = result.outputPath;
symlinkLoop = result.symlinkLoop;
}
// Prepend generated header (after frontmatter)
@@ -321,59 +384,80 @@ function findTemplates(): string[] {
return discoverTemplates(ROOT).map(t => path.join(ROOT, t.tmpl));
}
let hasChanges = false;
const tokenBudget: Array<{ skill: string; lines: number; tokens: number }> = [];
const ALL_HOSTS: Host[] = ['claude', 'codex', 'factory'];
const hostsToRun: Host[] = HOST_ARG_VAL === 'all' ? ALL_HOSTS : [HOST];
const failures: { host: string; error: Error }[] = [];
for (const tmplPath of findTemplates()) {
// Skip /codex skill for codex host (self-referential — it's a Claude wrapper around codex exec)
if (HOST === 'codex') {
const dir = path.basename(path.dirname(tmplPath));
if (dir === 'codex') continue;
}
for (const currentHost of hostsToRun) {
HOST = currentHost;
const { outputPath, content, symlinkLoop } = processTemplate(tmplPath, HOST);
const relOutput = path.relative(ROOT, outputPath);
try {
let hasChanges = false;
const tokenBudget: Array<{ skill: string; lines: number; tokens: number }> = [];
if (symlinkLoop) {
console.log(`SKIPPED (symlink loop): ${relOutput}`);
} else if (DRY_RUN) {
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
if (existing !== content) {
console.log(`STALE: ${relOutput}`);
hasChanges = true;
} else {
console.log(`FRESH: ${relOutput}`);
for (const tmplPath of findTemplates()) {
// Skip /codex skill for non-Claude hosts (it's a Claude wrapper around codex exec)
if (currentHost !== 'claude') {
const dir = path.basename(path.dirname(tmplPath));
if (dir === 'codex') continue;
}
const { outputPath, content, symlinkLoop } = processTemplate(tmplPath, currentHost);
const relOutput = path.relative(ROOT, outputPath);
if (symlinkLoop) {
console.log(`SKIPPED (symlink loop): ${relOutput}`);
} else if (DRY_RUN) {
const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : '';
if (existing !== content) {
console.log(`STALE: ${relOutput}`);
hasChanges = true;
} else {
console.log(`FRESH: ${relOutput}`);
}
} else {
fs.writeFileSync(outputPath, content);
console.log(`GENERATED: ${relOutput}`);
}
// Track token budget
const lines = content.split('\n').length;
const tokens = Math.round(content.length / 4); // ~4 chars per token
tokenBudget.push({ skill: relOutput, lines, tokens });
}
} else {
fs.writeFileSync(outputPath, content);
console.log(`GENERATED: ${relOutput}`);
if (DRY_RUN && hasChanges) {
console.error(`\nGenerated SKILL.md files are stale (${currentHost} host). Run: bun run gen:skill-docs --host ${currentHost}`);
if (HOST_ARG_VAL !== 'all') process.exit(1);
failures.push({ host: currentHost, error: new Error('Stale files detected') });
}
// Print token budget summary
if (!DRY_RUN && tokenBudget.length > 0) {
tokenBudget.sort((a, b) => b.lines - a.lines);
const totalLines = tokenBudget.reduce((s, t) => s + t.lines, 0);
const totalTokens = tokenBudget.reduce((s, t) => s + t.tokens, 0);
console.log('');
console.log(`Token Budget (${currentHost} host)`);
console.log('═'.repeat(60));
for (const t of tokenBudget) {
const name = t.skill.replace(/\/SKILL\.md$/, '').replace(/^\.(agents|factory)\/skills\//, '');
console.log(` ${name.padEnd(30)} ${String(t.lines).padStart(5)} lines ~${String(t.tokens).padStart(6)} tokens`);
}
console.log('─'.repeat(60));
console.log(` ${'TOTAL'.padEnd(30)} ${String(totalLines).padStart(5)} lines ~${String(totalTokens).padStart(6)} tokens`);
console.log('');
}
} catch (e) {
failures.push({ host: currentHost, error: e as Error });
console.error(`WARNING: ${currentHost} generation failed: ${(e as Error).message}`);
}
// Track token budget
const lines = content.split('\n').length;
const tokens = Math.round(content.length / 4); // ~4 chars per token
tokenBudget.push({ skill: relOutput, lines, tokens });
}
if (DRY_RUN && hasChanges) {
console.error('\nGenerated SKILL.md files are stale. Run: bun run gen:skill-docs');
process.exit(1);
}
// Print token budget summary
if (!DRY_RUN && tokenBudget.length > 0) {
tokenBudget.sort((a, b) => b.lines - a.lines);
const totalLines = tokenBudget.reduce((s, t) => s + t.lines, 0);
const totalTokens = tokenBudget.reduce((s, t) => s + t.tokens, 0);
console.log('');
console.log(`Token Budget (${HOST} host)`);
console.log('═'.repeat(60));
for (const t of tokenBudget) {
const name = t.skill.replace(/\/SKILL\.md$/, '').replace(/^\.agents\/skills\//, '');
console.log(` ${name.padEnd(30)} ${String(t.lines).padStart(5)} lines ~${String(t.tokens).padStart(6)} tokens`);
}
console.log('─'.repeat(60));
console.log(` ${'TOTAL'.padEnd(30)} ${String(totalLines).padStart(5)} lines ~${String(totalTokens).padStart(6)} tokens`);
console.log('');
// --host all: report failures. Only exit(1) if claude failed.
if (failures.length > 0 && HOST_ARG_VAL === 'all') {
console.error(`\n${failures.length} host(s) failed: ${failures.map(f => f.host).join(', ')}`);
if (failures.some(f => f.host === 'claude')) process.exit(1);
}
// Single host dry-run failure already handled above
+2 -1
View File
@@ -61,7 +61,8 @@ policy:
`;
}
export function codexSkillName(skillDir: string): string {
/** Compute skill name for external hosts (Codex, Factory, etc.) */
export function externalSkillName(skillDir: string): string {
if (skillDir === '.' || skillDir === '') return 'gstack';
// Don't double-prefix: gstack-upgrade → gstack-upgrade (not gstack-gstack-upgrade)
if (skillDir.startsWith('gstack-')) return skillDir;
+5 -3
View File
@@ -13,12 +13,14 @@ import type { TemplateContext } from './types';
*/
function generatePreambleBash(ctx: TemplateContext): string {
const runtimeRoot = ctx.host === 'codex'
const hostConfigDir: Record<string, string> = { codex: '.codex', factory: '.factory' };
const runtimeRoot = (ctx.host !== 'claude')
? `_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
GSTACK_ROOT="$HOME/.codex/skills/gstack"
[ -n "$_ROOT" ] && [ -d "$_ROOT/.agents/skills/gstack" ] && GSTACK_ROOT="$_ROOT/.agents/skills/gstack"
GSTACK_ROOT="$HOME/${hostConfigDir[ctx.host]}/skills/gstack"
[ -n "$_ROOT" ] && [ -d "$_ROOT/${ctx.paths.localSkillRoot}" ] && GSTACK_ROOT="$_ROOT/${ctx.paths.localSkillRoot}"
GSTACK_BIN="$GSTACK_ROOT/bin"
GSTACK_BROWSE="$GSTACK_ROOT/browse/dist"
GSTACK_DESIGN="$GSTACK_ROOT/design/dist"
`
: '';
+8 -1
View File
@@ -1,4 +1,4 @@
export type Host = 'claude' | 'codex';
export type Host = 'claude' | 'codex' | 'factory';
export interface HostPaths {
skillRoot: string;
@@ -23,6 +23,13 @@ export const HOST_PATHS: Record<Host, HostPaths> = {
browseDir: '$GSTACK_BROWSE',
designDir: '$GSTACK_DESIGN',
},
factory: {
skillRoot: '$GSTACK_ROOT',
localSkillRoot: '.factory/skills/gstack',
binDir: '$GSTACK_BIN',
browseDir: '$GSTACK_BROWSE',
designDir: '$GSTACK_DESIGN',
},
};
export interface TemplateContext {
+3
View File
@@ -370,5 +370,8 @@ export function generateCoAuthorTrailer(ctx: TemplateContext): string {
if (ctx.host === 'codex') {
return 'Co-Authored-By: OpenAI Codex <noreply@openai.com>';
}
if (ctx.host === 'factory') {
return 'Co-Authored-By: Factory Droid <droid@users.noreply.github.com>';
}
return 'Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>';
}
+45
View File
@@ -111,6 +111,37 @@ if (fs.existsSync(AGENTS_DIR)) {
console.log('\n Codex Skills: .agents/skills/ not found (run: bun run gen:skill-docs --host codex)');
}
// ─── Factory Skills ─────────────────────────────────────────
const FACTORY_DIR = path.join(ROOT, '.factory', 'skills');
if (fs.existsSync(FACTORY_DIR)) {
console.log('\n Factory Skills (.factory/skills/):');
const factoryDirs = fs.readdirSync(FACTORY_DIR).sort();
let factoryCount = 0;
let factoryMissing = 0;
for (const dir of factoryDirs) {
const skillMd = path.join(FACTORY_DIR, dir, 'SKILL.md');
if (fs.existsSync(skillMd)) {
factoryCount++;
const content = fs.readFileSync(skillMd, 'utf-8');
const hasClaude = content.includes('.claude/skills');
if (hasClaude) {
hasErrors = true;
console.log(` \u274c ${dir.padEnd(30)} — contains .claude/skills reference`);
} else {
console.log(` \u2705 ${dir.padEnd(30)} — OK`);
}
} else {
factoryMissing++;
hasErrors = true;
console.log(` \u274c ${dir.padEnd(30)} — SKILL.md missing`);
}
}
console.log(` Total: ${factoryCount} skills, ${factoryMissing} missing`);
} else {
console.log('\n Factory Skills: .factory/skills/ not found (run: bun run gen:skill-docs --host factory)');
}
// ─── Freshness ──────────────────────────────────────────────
console.log('\n Freshness (Claude):');
@@ -141,5 +172,19 @@ try {
console.log(' Run: bun run gen:skill-docs --host codex');
}
console.log('\n Freshness (Factory):');
try {
execSync('bun run scripts/gen-skill-docs.ts --host factory --dry-run', { cwd: ROOT, stdio: 'pipe' });
console.log(' \u2705 All Factory generated files are fresh');
} catch (err: any) {
hasErrors = true;
const output = err.stdout?.toString() || '';
console.log(' \u274c Factory generated files are stale:');
for (const line of output.split('\n').filter((l: string) => l.startsWith('STALE'))) {
console.log(` ${line}`);
}
console.log(' Run: bun run gen:skill-docs --host factory');
}
console.log('');
process.exit(hasErrors ? 1 : 0);