Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ bin/gstack-global-discover
.gstack/
.claude/skills/
.agents/
.pi/
.context/
/tmp/
*.log
Expand Down
46 changes: 46 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,52 @@ Three reasons:

Tier 1 runs on every `bun test`. Tiers 2+3 are gated behind `EVALS=1`. The idea is: catch 95% of issues for free, use LLMs only for judgment calls.

### Multi-host generation (Claude, Codex, Pi)

Claude-native `.tmpl` templates are transformed per host at generation time:

```
bun run gen:skill-docs # Claude (default) → {skill}/SKILL.md
bun run gen:skill-docs --host codex # Codex → .agents/skills/gstack-{name}/SKILL.md
bun run gen:skill-docs --host pi # Pi → .pi/skills/gstack-{name}/SKILL.md
```

**Shared transforms (Pi + Codex vs Claude):** Frontmatter stripped to `name:` + `description:` only (Claude's `allowed-tools`/`hooks`/`version` removed; hook-based skills get inline safety prose instead). Preamble uses `$GSTACK_ROOT`/`$GSTACK_BIN`/`$GSTACK_BROWSE` with repo-local-first resolution. `/codex` skill excluded (self-referential). All `~/.claude/` and `.claude/` paths rewritten to host equivalents. Skill naming via `codexSkillName()`: root → `gstack`, subdirs → `gstack-{dir}`, existing `gstack-` prefix preserved.

#### Pi vs Codex differences

| Aspect | Pi | Codex |
|--------|-----|-------|
| **Output / global / local dirs** | `.pi/skills/`, `~/.pi/agent/skills/`, `.pi/skills/` | `.agents/skills/`, `~/.codex/skills/`, `.agents/skills/` |
| **name: field** | Must match dir name (`gstack-review`) — Pi validates at runtime | Passes through unchanged |
| **openai.yaml** | Not generated | Generated per skill |
| **Codex CLI sections** | Kept (Pi users may have `codex`) | Stripped (self-referential) |
| **Setup install** | `cp` (avoids dangling symlinks) | `ln -snf` (supports dual-path discovery) |
| **Migration** | None (new host) | `migrate_direct_codex_install()` |
| **Repo-local detection** | Not yet implemented | `CODEX_REPO_LOCAL` flag |

#### Path rewrite rules

Order matters — specific before general to avoid prefix collisions:

```
~/.claude/skills/gstack → $GSTACK_ROOT # both hosts
.claude/skills/gstack → .{pi,agents}/skills/gstack
.claude/skills/review → .{pi,agents}/skills/gstack/review ← sidecar!
.claude/skills → .{pi,agents}/skills
```

The `review → gstack/review` rewrite is critical: review sidecars (checklist.md, design-checklist.md, etc.) live under the `gstack/` runtime root, not under `gstack-review/`.

#### Pi setup flow (`setup` sections 1c + 7)

1. **Generate** (`INSTALL_PI=1`): Runs `gen:skill-docs --host pi` explicitly (not included in `bun run build`).
2. **Runtime root**: Creates `~/.pi/agent/skills/gstack/` with symlinked assets (`bin/`, `browse/dist`, `browse/bin`, `ETHOS.md`, review sidecars).
3. **Install**: Copies each `SKILL.md` from generated `.pi/skills/gstack-*/` into `~/.pi/agent/skills/gstack-*/`.
4. **Cleanup**: Removes stale whole-directory symlinks from previous layouts.

Intentionally omitted vs Codex: no dedicated `create_pi_runtime_root()` (inline suffices), no `create_agents_sidecar`, no migration logic, no repo-local detection (future work).

## Command dispatch

Commands are categorized by side effects:
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ cd ~/gstack && ./setup --host auto

For Codex-compatible hosts, setup now supports both repo-local installs from `.agents/skills/gstack` and user-global installs from `~/.codex/skills/gstack`. All 28 skills work across all supported agents. Hook-based safety skills (careful, freeze, guard) use inline safety advisory prose on non-Claude hosts.

### Pi

[Pi](https://github.com/mariozechner/pi-coding-agent) implements the [Agent Skills standard](https://agentskills.io/specification). Skills live in `~/.pi/agent/skills/` and are discovered automatically.

Install once for your user account:

```bash
git clone https://github.com/garrytan/gstack.git ~/gstack
cd ~/gstack && ./setup --host pi
```

`setup --host pi` copies skills (with rewritten paths) to `~/.pi/agent/skills/` and symlinks runtime assets back to the checkout. Run `./setup --host pi` again after `git pull` to refresh, or use `/skill:gstack-upgrade`.

## See it work

```
Expand Down
6 changes: 6 additions & 0 deletions gstack-upgrade/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,18 @@ elif [ -d ".claude/skills/gstack/.git" ]; then
elif [ -d ".agents/skills/gstack/.git" ]; then
INSTALL_TYPE="local-git"
INSTALL_DIR=".agents/skills/gstack"
elif [ -d ".pi/skills/gstack/.git" ]; then
INSTALL_TYPE="local-git"
INSTALL_DIR=".pi/skills/gstack"
elif [ -d ".claude/skills/gstack" ]; then
INSTALL_TYPE="vendored"
INSTALL_DIR=".claude/skills/gstack"
elif [ -d "$HOME/.claude/skills/gstack" ]; then
INSTALL_TYPE="vendored-global"
INSTALL_DIR="$HOME/.claude/skills/gstack"
elif [ -d "$HOME/.pi/agent/skills/gstack" ]; then
INSTALL_TYPE="vendored-global"
INSTALL_DIR="$HOME/.pi/agent/skills/gstack"
else
echo "ERROR: gstack not found"
exit 1
Expand Down
6 changes: 6 additions & 0 deletions gstack-upgrade/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,18 @@ elif [ -d ".claude/skills/gstack/.git" ]; then
elif [ -d ".agents/skills/gstack/.git" ]; then
INSTALL_TYPE="local-git"
INSTALL_DIR=".agents/skills/gstack"
elif [ -d ".pi/skills/gstack/.git" ]; then
INSTALL_TYPE="local-git"
INSTALL_DIR=".pi/skills/gstack"
elif [ -d ".claude/skills/gstack" ]; then
INSTALL_TYPE="vendored"
INSTALL_DIR=".claude/skills/gstack"
elif [ -d "$HOME/.claude/skills/gstack" ]; then
INSTALL_TYPE="vendored-global"
INSTALL_DIR="$HOME/.claude/skills/gstack"
elif [ -d "$HOME/.pi/agent/skills/gstack" ]; then
INSTALL_TYPE="vendored-global"
INSTALL_DIR="$HOME/.pi/agent/skills/gstack"
else
echo "ERROR: gstack not found"
exit 1
Expand Down
74 changes: 62 additions & 12 deletions scripts/gen-skill-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,17 @@ const DRY_RUN = process.argv.includes('--dry-run');

// ─── Template Context ───────────────────────────────────────

type Host = 'claude' | 'codex';
type Host = 'claude' | 'codex' | 'pi';
const OPENAI_SHORT_DESCRIPTION_LIMIT = 120;

const HOST_ARG = process.argv.find(a => a.startsWith('--host'));
const HOST: Host = (() => {
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 === 'pi') return 'pi';
if (val === 'claude') return 'claude';
throw new Error(`Unknown host: ${val}. Use claude, codex, or agents.`);
throw new Error(`Unknown host: ${val}. Use claude, codex, pi, or agents.`);
})();

interface HostPaths {
Expand All @@ -38,6 +39,10 @@ interface HostPaths {
browseDir: string;
}

// Pi vs Codex host differences: see ARCHITECTURE.md § "Multi-host generation"
// for the full reference (shared behavior, unique behavior, path rewrite rules,
// setup install flow, and what Pi intentionally omits vs Codex).

const HOST_PATHS: Record<Host, HostPaths> = {
claude: {
skillRoot: '~/.claude/skills/gstack',
Expand All @@ -51,6 +56,14 @@ const HOST_PATHS: Record<Host, HostPaths> = {
binDir: '$GSTACK_BIN',
browseDir: '$GSTACK_BROWSE',
},
pi: {
// Pi shares Codex's dynamic $GSTACK_ROOT pattern but resolves to
// ~/.pi/agent/skills/gstack at runtime (see generatePreambleBash).
skillRoot: '$GSTACK_ROOT',
localSkillRoot: '.pi/skills/gstack',
binDir: '$GSTACK_BIN',
browseDir: '$GSTACK_BROWSE',
},
};

interface TemplateContext {
Expand Down Expand Up @@ -177,12 +190,24 @@ function generateSnapshotFlags(_ctx: TemplateContext): string {
}

function generatePreambleBash(ctx: TemplateContext): string {
// Pi and Codex both use dynamic $GSTACK_ROOT resolution with a repo-local
// override. The pattern is identical — only the paths differ:
// Codex: default $HOME/.codex/skills/gstack, override .agents/skills/gstack
// Pi: default $HOME/.pi/agent/skills/gstack, override .pi/skills/gstack
// Claude uses hardcoded ~/.claude/skills/gstack paths (no $GSTACK_ROOT).
const runtimeRoot = ctx.host === 'codex'
? `_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_BIN="$GSTACK_ROOT/bin"
GSTACK_BROWSE="$GSTACK_ROOT/browse/dist"
`
: ctx.host === 'pi'
? `_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
GSTACK_ROOT="$HOME/.pi/agent/skills/gstack"
[ -n "$_ROOT" ] && [ -d "$_ROOT/.pi/skills/gstack" ] && GSTACK_ROOT="$_ROOT/.pi/skills/gstack"
GSTACK_BIN="$GSTACK_ROOT/bin"
GSTACK_BROWSE="$GSTACK_ROOT/browse/dist"
`
: '';

Expand Down Expand Up @@ -2896,9 +2921,14 @@ policy:
}

/**
* Transform frontmatter for Codex: keep only name + description.
* Transform frontmatter for Codex/Pi: keep only name + description.
* Strips allowed-tools, hooks, version, and all other fields.
* Handles multiline block scalar descriptions (YAML | syntax).
*
* Pi-specific: the name: field is prefixed with 'gstack-' (e.g., 'review'
* becomes 'gstack-review') because Pi validates that name: matches the
* parent directory name at runtime. Codex doesn't enforce this, so Codex
* names pass through unchanged.
*/
function transformFrontmatter(content: string, host: Host): string {
if (host === 'claude') return content;
Expand All @@ -2908,7 +2938,13 @@ function transformFrontmatter(content: string, host: Host): string {
const fmEnd = content.indexOf('\n---', fmStart + 4);
if (fmEnd === -1) return content;
const body = content.slice(fmEnd + 4); // includes the leading \n after ---
const { name, description } = extractNameAndDescription(content);
const { name: rawName, description } = extractNameAndDescription(content);

// For pi host, prefix name with gstack- to match directory convention (codexSkillName)
// Pi validates that the name: field matches the parent directory name
const name = (host === 'pi' && rawName && rawName !== 'gstack' && !rawName.startsWith('gstack-'))
? `gstack-${rawName}`
: rawName;

// Codex 1024-char description limit — fail build, don't ship broken skills
const MAX_DESC = 1024;
Expand Down Expand Up @@ -2969,12 +3005,17 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath:
// Determine skill directory relative to ROOT
const skillDir = path.relative(ROOT, path.dirname(tmplPath));

// For codex host, route output to .agents/skills/{codexSkillName}/SKILL.md
// For codex/pi hosts, route output to {host-dir}/skills/{skillName}/SKILL.md
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');
} else if (host === 'pi') {
const piName = codexSkillName(skillDir === '.' ? '' : skillDir);
outputDir = path.join(ROOT, '.pi', 'skills', piName);
fs.mkdirSync(outputDir, { recursive: true });
outputPath = path.join(outputDir, 'SKILL.md');
}

// Extract skill name from frontmatter for TemplateContext
Expand Down Expand Up @@ -3002,8 +3043,8 @@ 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') {
// For codex/pi hosts: transform frontmatter and replace Claude-specific paths
if (host === 'codex' || host === 'pi') {
// Extract hook safety prose BEFORE transforming frontmatter (which strips hooks)
const safetyProse = extractHookSafetyProse(tmplContent);

Expand All @@ -3019,10 +3060,19 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath:
// 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 (host === 'codex') {
content = content.replace(/\.claude\/skills\/review/g, '.agents/skills/gstack/review');
content = content.replace(/\.claude\/skills/g, '.agents/skills');
} else {
content = content.replace(/\.claude\/skills\/review/g, '.pi/skills/gstack/review');
content = content.replace(/\.claude\/skills/g, '.pi/skills');
}

if (outputDir) {
// Write openai.yaml agent metadata (codex only).
// Pi doesn't generate openai.yaml — Pi's skill discovery reads the
// name: and description: fields directly from SKILL.md frontmatter.
// Codex needs the separate openai.yaml for its skill browsing UI.
if (host === 'codex' && outputDir) {
const codexName = codexSkillName(skillDir === '.' ? '' : skillDir);
const agentsDir = path.join(outputDir, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
Expand Down Expand Up @@ -3063,8 +3113,8 @@ function findTemplates(): string[] {
let hasChanges = false;

for (const tmplPath of findTemplates()) {
// Skip /codex skill for codex host (self-referential — it's a Claude wrapper around codex exec)
if (HOST === 'codex') {
// Skip /codex skill for codex/pi hosts (self-referential — it's a Claude wrapper around codex exec)
if (HOST === 'codex' || HOST === 'pi') {
const dir = path.basename(path.dirname(tmplPath));
if (dir === 'codex') continue;
}
Expand Down
Loading