feat: Factory Droid compatibility — works across Claude Code, Codex, and Factory (v0.13.5.0) (#621)

* refactor: extract processExternalHost() shared helper for multi-host generation

Refactor the Codex-specific output routing block in gen-skill-docs.ts into
a shared processExternalHost() function. Both Codex and future external hosts
(Factory Droid) will use this helper for output routing, symlink loop detection,
frontmatter transformation, path rewrites, and metadata generation.

- Rename codexSkillName() to externalSkillName() everywhere
- Extract ExternalHostConfig interface with per-host settings
- Codex output is byte-identical (verified via --dry-run)
- Skip /codex skill for all non-Claude hosts (not just codex)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Factory Droid host type, preamble, and co-author trailer

- Add 'factory' to Host union type with .factory/skills/gstack paths
- Extend preamble runtime root detection for Factory ($HOME/.factory/)
- Add GSTACK_DESIGN env var to preamble (was missing for Codex too)
- Add Factory Droid co-author trailer for git commits

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: Factory Droid generation, --host all, and host-aware frontmatter

- Add --host factory (alias: --host droid) to gen-skill-docs
- Add --host all: generates for claude, codex, and factory in one invocation
  with fault-tolerant per-host error handling (only fails if claude fails)
- Factory frontmatter: name + description + user-invocable: true
- Factory sensitive skills: disable-model-invocation: true (from sensitive: field)
- Claude: strips sensitive: field from output (only Factory uses it)
- Factory tool name translation: Claude tool names → generic phrasing
- Replace chained gen:skill-docs calls with --host all in package.json build

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: sensitive frontmatter for Factory Droid auto-invocation safety

Add sensitive: true to 6 skill templates with side effects that Factory
Droids shouldn't auto-invoke (ship, land-and-deploy, guard, careful,
freeze, unfreeze). The field is:
- Factory: emitted as disable-model-invocation: true
- Claude/Codex: stripped from output by transformFrontmatter()

Also fix Claude host path: call transformFrontmatter() for Claude to
strip the sensitive: field from Claude output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: gstack-platform-detect binary for multi-host debugging

Bash script that prints a table of installed AI coding agents (Claude,
Codex, Factory Droid, Kiro) with versions, skill paths, and gstack
installation status. Useful for debugging multi-host setups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: Factory Droid support in setup script

- Add factory to --host values (auto-detected via command -v droid)
- Add .factory/ skill doc generation step alongside .agents/
- Add create_factory_runtime_root() and link_factory_skill_dirs()
  helpers mirroring the Codex equivalents
- Factory install section creates ~/.factory/skills/ with symlinks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: Factory Droid awareness in skill-check and uninstall

- skill-check.ts: add Factory skills validation and freshness check
- gstack-uninstall: add Factory artifact cleanup (~/.factory/skills/gstack*
  and per-project .factory/ sidecar)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: Factory Droid generation + --host all test suites

Add 13 new tests:
- Factory output paths, frontmatter (user-invocable, disable-model-invocation)
- Sensitive vs non-sensitive skill classification
- Path rewrites (no .claude/skills/ in Factory output)
- /codex skill exclusion, openai.yaml absence
- Factory keeps Codex integration blocks (for second opinions)
- --host droid alias, --dry-run freshness, preamble paths
- --host all generates for all 3 hosts
- Setup script host validation updated for factory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: Factory Droid install instructions + CI freshness check

- README: add Factory Droid section with install instructions and
  restart note (Factory requires restart to rescan skills)
- CI: add Factory skill doc freshness verification to skill-docs.yml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: generated Factory Droid skill output (.factory/skills/)

29 skills generated for Factory Droid with:
- user-invocable: true on all skills
- disable-model-invocation: true on 6 sensitive skills
- .factory/skills/ paths (no .claude/skills/ references)
- $GSTACK_ROOT env vars for runtime root detection
- Tool name translation (Claude tool names → generic phrasing)

Committed to git for CI freshness checks and direct consumption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add Factory Droid P1 TODO for browse MCP server

Add 3 TODOs under new ## Factory Droid section:
- P1: Browse MCP server (Option B, deeper Factory integration)
- P3: .agent/skills/ dual output for cross-agent compatibility
- P3: Custom Droid definitions alongside skills

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.13.5.0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-29 09:57:34 -06:00
committed by GitHub
parent ea7dbc9a39
commit 484cf1fb3b
51 changed files with 23721 additions and 135 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