Files
gstack/lib/worktree.ts
Garry Tan 04b709d91a feat: declarative multi-host platform + OpenCode, Slate, Cursor, OpenClaw (v0.15.5.0) (#793)
* test: add golden-file baselines for host config refactor

Snapshot generated SKILL.md output for ship skill across all 3 existing
hosts (Claude, Codex, Factory). These baselines verify the config-driven
refactor produces identical output to the current hardcoded system.

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

* feat: add HostConfig interface and validator for declarative host system

New scripts/host-config.ts defines the typed HostConfig interface that
captures all per-host variation: paths, frontmatter rules, path/tool
rewrites, suppressed resolvers, runtime root symlinks, install strategy,
and behavioral config (co-author trailer, learnings mode, boundary
instruction). Includes validateHostConfig() and validateAllConfigs() with
regex-based security validation and cross-config uniqueness checks.

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

* feat: add typed host configs for Claude, Codex, Factory, and Kiro

Extract all hardcoded host-specific values from gen-skill-docs.ts,
types.ts, preamble.ts, review.ts, and setup into typed HostConfig
objects. Each host is a single file in hosts/ with its paths, frontmatter
rules, path/tool rewrites, runtime root manifest, and install behavior.

hosts/index.ts exports all configs, derives the Host type, and provides
resolveHostArg() for CLI alias handling (e.g., 'agents' -> 'codex',
'droid' -> 'factory').

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

* refactor: derive Host type and HOST_PATHS from host configs

types.ts no longer hardcodes host names or paths. The Host type is
derived from ALL_HOST_CONFIGS in hosts/index.ts, and HOST_PATHS is
built dynamically from each config's globalRoot/localSkillRoot/usesEnvVars.
Adding a new host to hosts/index.ts automatically extends the type system.

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

* refactor: gen-skill-docs.ts consumes typed host configs

Replace hardcoded EXTERNAL_HOST_CONFIG, transformFrontmatter host
branches, path/tool rewrite if-chains, and ALL_HOSTS array with
config-driven lookups from hosts/*.ts.

- Host detection uses resolveHostArg() (handles aliases like agents/droid)
- transformFrontmatter uses config's allowlist/denylist mode, extraFields,
  conditionalFields, renameFields, and descriptionLimitBehavior
- Path rewrites use config's pathRewrites array (replaceAll, order matters)
- Tool rewrites use config's toolRewrites object
- Skill skipping uses config's generation.skipSkills
- ALL_HOSTS derived from ALL_HOST_NAMES
- Token budget display regex derived from host configs

Golden-file comparison: all 3 hosts produce IDENTICAL output to baselines.

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

* refactor: preamble, co-author trailer, and resolver suppression use host configs

- preamble.ts: hostConfigDir derived from config.globalRoot instead of
  hardcoded Record
- utility.ts: generateCoAuthorTrailer reads from config.coAuthorTrailer
  instead of host switch statement
- gen-skill-docs.ts: suppressedResolvers from config skip resolver
  execution at placeholder replacement time (belt+suspenders with
  existing ctx.host checks in individual resolvers)

Golden-file comparison: all 3 hosts produce IDENTICAL output to baselines.

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

* refactor: setup tooling uses config-driven host detection

- host-config-export.ts: new CLI that exposes host configs to bash
  (list, get, detect, validate, symlinks commands)
- bin/gstack-platform-detect: reads host configs instead of hardcoded
  binary/path mapping
- scripts/skill-check.ts: iterates host configs for skill validation
  and freshness checks instead of separate Codex/Factory blocks
- lib/worktree.ts: iterates host configs for directory copy instead
  of hardcoded .agents

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

* feat: add OpenCode, Slate, and Cursor host configs

Three new hosts added to the declarative config system. Each is a typed
HostConfig object with paths, frontmatter rules, and path rewrites.
All generate valid SKILL.md output with zero .claude/skills path leakage.

- hosts/opencode.ts: OpenCode (opencode.ai), skills at ~/.config/opencode/
- hosts/slate.ts: Slate (Random Labs), skills at ~/.slate/
- hosts/cursor.ts: Cursor, skills at ~/.cursor/
- .gitignore: add .kiro/, .opencode/, .slate/, .cursor/, .openclaw/

Zero code changes needed — just config files + re-export in index.ts.

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

* feat: add OpenClaw host config with adapter for tool mapping

OpenClaw gets a hybrid approach: typed config for paths/frontmatter/
detection + a post-processing adapter for semantic tool rewrites.

Config handles: path rewrites, frontmatter (name+description+version),
CLAUDE.md→AGENTS.md, tool name rewrites (Bash→exec, Read→read, etc.),
suppressed resolvers, SOUL.md via staticFiles.

Adapter handles: AskUserQuestion→prose, Agent→sessions_spawn, $B→exec $B.

Zero .claude/skills path leakage. Zero hardcoded tool references remaining.

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

* feat: contributor add-host skill + fix version sync

- contrib/add-host/SKILL.md.tmpl: contributor-only skill that guides
  new host config creation. Lives in contrib/, excluded from user installs.
- package.json: sync version with VERSION file (0.15.2.1)

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

* test: add parameterized host smoke tests for all hosts

35 new tests covering all 7 external hosts (Codex, Factory, Kiro,
OpenCode, Slate, Cursor, OpenClaw). Each host gets 4-5 tests:
- output exists on disk with SKILL.md files
- no .claude/skills path leakage in non-root skills
- frontmatter has name + description fields
- --dry-run freshness check passes
- /codex skill excluded (for hosts with skipSkills: ['codex'])

Tests are parameterized over ALL_HOST_CONFIGS so adding a new host
automatically gets smoke-tested with zero new test code.

Also updates --host all test to verify all registered hosts generate.

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

* test: 100% coverage for host config system

71 new tests in test/host-config.test.ts covering:
- hosts/index.ts: ALL_HOST_CONFIGS, getHostConfig, resolveHostArg (aliases),
  getExternalHosts, uniqueness checks
- host-config.ts validateHostConfig: name regex, displayName, cliCommand,
  cliAliases, globalRoot, localSkillRoot, hostSubdir, frontmatter.mode,
  linkingStrategy, shell injection attempts, paths with $ and ~
- host-config.ts validateAllConfigs: duplicate name/hostSubdir/globalRoot
  detection, error prefix format, real configs pass
- HOST_PATHS derivation: env vars for external hosts, literal paths for
  Claude, localSkillRoot matches config, every host has entry
- host-config-export.ts CLI: list, get (string/boolean/array), detect,
  validate, symlinks, error cases (missing args, unknown field/host)
- Golden-file regression: claude/codex/factory ship SKILL.md vs baselines
- Individual host config correctness: prefixable, linkingStrategy,
  usesEnvVars, description limits, metadata, sidecar, tool rewrites,
  conditional fields, suppressed resolvers, boundary instruction,
  co-author trailers, skip rules, path rewrites, runtime root assets

Combined with the 35 parameterized smoke tests from gen-skill-docs.test.ts,
total new test coverage for multi-host: 106 tests.

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

* chore: update golden baselines and sync version after merge from main

Golden files refreshed to match post-merge generated output. package.json
version synced to VERSION file (0.15.4.0).

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

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

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

* fix: sidebar E2E tests now self-contained and passing

- sidebar-url-accuracy: fix stale assertion that expected extensionUrl
  in prompt text (prompt format changed, URL is now in pageUrl field)
- sidebar-css-interaction: simplify task from multi-step HN comment
  navigation to single-page example.com style injection (faster, more
  reliable, still exercises goto + style + completion flow)
- Update golden baselines after merge from main

All 3 sidebar tests now pass: 3/3, 0 fail, ~36s total.

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

* docs: add ADDING_A_HOST.md guide + update docs for multi-host system

- docs/ADDING_A_HOST.md: step-by-step guide for adding a new host
  (create config, register, gitignore, generate, test). Covers the
  full HostConfig interface, adapter pattern, and validation.
- CONTRIBUTING.md: replace stale "Dual-host development" section with
  "Multi-host development" covering all 8 hosts and linking to the guide.
- README.md: consolidate Codex/Factory install sections into one
  "Other AI Agents" section listing all supported hosts with auto-detect.
- CLAUDE.md: add hosts/, host-config.ts, host-adapters/, contrib/ to
  project structure tree.

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

* docs: README per-host install instructions for all 8 agents

Each supported agent now has its own copy-paste install block with
the exact command and where skills end up on disk. Includes: auto-detect,
Codex, OpenCode, Cursor, Factory, OpenClaw, Slate, and Kiro.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:32:20 -07:00

303 lines
9.3 KiB
TypeScript

/**
* Git worktree manager for isolated test execution with change harvesting.
*
* Creates git worktrees for test suites that need real repo context,
* harvests any changes the test agent makes as patches, and provides
* deduplication across runs.
*
* Reusable platform module — future /batch or /codex challenge skills
* can import this directly.
*/
import { spawnSync } from 'child_process';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// --- Interfaces ---
export interface WorktreeInfo {
path: string;
testName: string;
originalSha: string;
createdAt: number;
}
export interface HarvestResult {
testName: string;
worktreePath: string;
diffStat: string;
patchPath: string;
changedFiles: string[];
isDuplicate: boolean;
}
// --- Utility ---
/** Recursive directory copy (pure TypeScript, no external deps). */
function copyDirSync(src: string, dest: string): void {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
// Skip symlinks to avoid infinite recursion (e.g., .claude/skills/gstack → repo root)
if (entry.isSymbolicLink()) continue;
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/** Run a git command and return stdout. Throws on failure unless tolerateFailure is set. */
function git(args: string[], cwd: string, tolerateFailure = false): string {
const result = spawnSync('git', args, { cwd, stdio: 'pipe', timeout: 30_000 });
const stdout = result.stdout?.toString().trim() ?? '';
const stderr = result.stderr?.toString().trim() ?? '';
if (result.status !== 0 && !tolerateFailure) {
throw new Error(`git ${args.join(' ')} failed (exit ${result.status}): ${stderr || stdout}`);
}
return stdout;
}
// --- Dedup index ---
interface DedupIndex {
hashes: Record<string, string>; // hash → first-seen runId
}
function getDedupPath(): string {
return path.join(os.homedir(), '.gstack-dev', 'harvests', 'dedup.json');
}
function loadDedupIndex(): DedupIndex {
try {
const raw = fs.readFileSync(getDedupPath(), 'utf-8');
return JSON.parse(raw);
} catch {
return { hashes: {} };
}
}
function saveDedupIndex(index: DedupIndex): void {
const dir = path.dirname(getDedupPath());
fs.mkdirSync(dir, { recursive: true });
const tmp = getDedupPath() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(index, null, 2));
fs.renameSync(tmp, getDedupPath());
}
// --- WorktreeManager ---
export class WorktreeManager {
private repoRoot: string;
private runId: string;
private active: Map<string, WorktreeInfo> = new Map();
private harvestResults: HarvestResult[] = [];
constructor(repoRoot?: string) {
if (repoRoot) {
this.repoRoot = repoRoot;
} else {
this.repoRoot = git(['rev-parse', '--show-toplevel'], process.cwd());
}
this.runId = crypto.randomUUID();
// Register cleanup on process exit
process.on('exit', () => {
this.cleanupAll();
});
}
/** Create an isolated worktree. Returns the worktree path. Throws on failure. */
create(testName: string): string {
const originalSha = git(['rev-parse', 'HEAD'], this.repoRoot);
const worktreeBase = path.join(this.repoRoot, '.gstack-worktrees', this.runId);
fs.mkdirSync(worktreeBase, { recursive: true });
const worktreePath = path.join(worktreeBase, testName);
// Create detached worktree at current HEAD
git(['worktree', 'add', '--detach', worktreePath, 'HEAD'], this.repoRoot);
// Copy gitignored build artifacts that tests need (config-driven)
const { getExternalHosts } = require('../hosts/index');
for (const hostConfig of getExternalHosts()) {
const hostSrc = path.join(this.repoRoot, hostConfig.hostSubdir);
if (fs.existsSync(hostSrc)) {
copyDirSync(hostSrc, path.join(worktreePath, hostConfig.hostSubdir));
}
}
const browseDist = path.join(this.repoRoot, 'browse', 'dist');
if (fs.existsSync(browseDist)) {
copyDirSync(browseDist, path.join(worktreePath, 'browse', 'dist'));
}
const info: WorktreeInfo = {
path: worktreePath,
testName,
originalSha,
createdAt: Date.now(),
};
this.active.set(testName, info);
return worktreePath;
}
/** Harvest changes from a worktree. Returns null if clean or on error. */
harvest(testName: string): HarvestResult | null {
const info = this.active.get(testName);
if (!info) return null;
try {
// Check if worktree directory still exists (agent may have deleted it)
if (!fs.existsSync(info.path)) {
process.stderr.write(` HARVEST [${testName}]: worktree dir deleted, skipping\n`);
return null;
}
// Stage everything including untracked files
git(['-C', info.path, 'add', '-A'], info.path, true);
// Get diff against original SHA (captures both committed and uncommitted changes)
const patch = git(['-C', info.path, 'diff', info.originalSha, '--cached'], info.path, true);
if (!patch) return null;
// Get diff stat for human-readable output
const diffStat = git(['-C', info.path, 'diff', info.originalSha, '--cached', '--stat'], info.path, true);
// Get changed file names
const nameOnly = git(['-C', info.path, 'diff', info.originalSha, '--cached', '--name-only'], info.path, true);
const changedFiles = nameOnly.split('\n').filter(Boolean);
// Dedup check
const hash = crypto.createHash('sha256').update(patch).digest('hex');
const dedupIndex = loadDedupIndex();
const isDuplicate = hash in dedupIndex.hashes;
let patchPath = '';
if (!isDuplicate) {
// Save patch
const harvestDir = path.join(os.homedir(), '.gstack-dev', 'harvests', this.runId);
fs.mkdirSync(harvestDir, { recursive: true });
patchPath = path.join(harvestDir, `${testName}.patch`);
fs.writeFileSync(patchPath, patch);
// Update dedup index
dedupIndex.hashes[hash] = this.runId;
saveDedupIndex(dedupIndex);
}
const result: HarvestResult = {
testName,
worktreePath: info.path,
diffStat,
patchPath,
changedFiles,
isDuplicate,
};
this.harvestResults.push(result);
return result;
} catch (err) {
process.stderr.write(` HARVEST [${testName}]: error — ${err}\n`);
return null;
}
}
/** Remove a worktree. Non-fatal on error. */
cleanup(testName: string): void {
const info = this.active.get(testName);
if (!info) return;
try {
git(['worktree', 'remove', '--force', info.path], this.repoRoot, true);
} catch {
// Force remove the directory if git worktree remove fails
try {
fs.rmSync(info.path, { recursive: true, force: true });
git(['worktree', 'prune'], this.repoRoot, true);
} catch { /* non-fatal */ }
}
this.active.delete(testName);
}
/** Force-remove all active worktrees (for process exit handler). */
cleanupAll(): void {
for (const testName of [...this.active.keys()]) {
this.cleanup(testName);
}
// Clean up the run directory if empty
const runDir = path.join(this.repoRoot, '.gstack-worktrees', this.runId);
try {
const entries = fs.readdirSync(runDir);
if (entries.length === 0) {
fs.rmdirSync(runDir);
}
} catch { /* non-fatal */ }
}
/** Remove worktrees from previous runs that weren't cleaned up. */
pruneStale(): void {
try {
git(['worktree', 'prune'], this.repoRoot, true);
const worktreeBase = path.join(this.repoRoot, '.gstack-worktrees');
if (!fs.existsSync(worktreeBase)) return;
for (const entry of fs.readdirSync(worktreeBase)) {
// Don't prune our own run
if (entry === this.runId) continue;
const entryPath = path.join(worktreeBase, entry);
try {
fs.rmSync(entryPath, { recursive: true, force: true });
} catch { /* non-fatal */ }
}
} catch {
process.stderr.write(' WORKTREE: prune failed (non-fatal)\n');
}
}
/** Print harvest report summary. */
printReport(): void {
if (this.harvestResults.length === 0) return;
const nonDuplicates = this.harvestResults.filter(r => !r.isDuplicate);
process.stderr.write('\n=== HARVEST REPORT ===\n');
process.stderr.write(`${nonDuplicates.length} of ${this.harvestResults.length} test suites produced new changes:\n\n`);
for (const result of this.harvestResults) {
if (result.isDuplicate) {
process.stderr.write(` ${result.testName}: duplicate patch (skipped)\n`);
} else {
process.stderr.write(` ${result.testName}: ${result.changedFiles.length} files changed\n`);
process.stderr.write(` Patch: ${result.patchPath}\n`);
process.stderr.write(` Apply: git apply ${result.patchPath}\n`);
if (result.diffStat) {
process.stderr.write(` ${result.diffStat}\n`);
}
}
process.stderr.write('\n');
}
}
/** Get the run ID (for testing). */
getRunId(): string {
return this.runId;
}
/** Get active worktree info (for testing). */
getInfo(testName: string): WorktreeInfo | undefined {
return this.active.get(testName);
}
}