diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 9116ef568..c11ae8ef3 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -75,7 +75,8 @@ while [ $# -gt 0 ]; do esac shift done -[ -n "$MODEL" ] || MODEL=${GSTACK_MODEL:-} +MODEL_SOURCE=model-flag +[ -n "$MODEL" ] || { MODEL=${GSTACK_MODEL:-}; MODEL_SOURCE=GSTACK_MODEL; } [ -n "$FOR" ] && [ -n "$REQUIRE" ] && { echo "gstack-capy-install: --for and --require are exclusive" >&2; exit 2; } [ -z "$FOR" ] && [ -z "$REQUIRE" ] && REQUIRE="claude,codex,patch" @@ -722,8 +723,12 @@ fi record_verdict "$TRAILER" "$([ "$RC" -eq 0 ] && echo 1 || echo 0)" LOADOUT_LINES="" if [ "$RC" -eq 0 ] && [ -n "$MODEL" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_FILE" ]; then - _lo_full="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills" - _lo_out=$(cd "$ROOT" && bin/gstack-modules install --model "$MODEL" --full-root "$_lo_full" 2>&1) + _lo_full=$(. "$SELF/bin/gstack-claude-root.sh" && dirname "$(gstack_canonical_claude_root)") + if [ "$MODEL_SOURCE" = model-flag ]; then + _lo_out=$(cd "$ROOT" && bin/gstack-modules install --model "$MODEL" --full-root "$_lo_full" 2>&1) + else + _lo_out=$(cd "$ROOT" && GSTACK_MODEL=$MODEL bin/gstack-modules install --full-root "$_lo_full" 2>&1) + fi _lo_line=$(printf '%s\n' "$_lo_out" | grep -m1 '^LOADOUT: ') _lo_root=$(printf '%s\n' "$_lo_out" | sed -n 's/^SKILL_ROOT: //p' | head -1) _lo_notes=$(printf '%s\n' "$_lo_out" | grep -E '^(LOADOUT_INVALID_MODEL|LOADOUT_FALLBACK): ') @@ -731,15 +736,17 @@ if [ "$RC" -eq 0 ] && [ -n "$MODEL" ] && [ "$PROFILE" -eq 0 ] && [ -n "$LAUNCH_F _lo_digest=$(printf '%s' "$_lo_line" | sed -n 's/.* digest=\([0-9a-f]\{12\}\) .*/\1/p') if [ -n "$_lo_name" ] && [ "$_lo_name" != full ]; then _lo_file="${VERDICT%.json}.loadout-$_lo_name-$_lo_digest.env" - _lo_tmp=$(umask 077 && mktemp "$_lo_file.tmp.XXXXXX") \ + [ -n "$_lo_digest" ] && [ -d "$_lo_root" ] \ + && _lo_tmp=$(umask 077 && mktemp "$_lo_file.tmp.XXXXXX") \ && { cat "$LAUNCH_FILE" && echo "export GSTACK_LOADOUT_SKILL_ROOT=$(shq "$_lo_root")"; } > "$_lo_tmp" \ - && chmod 0600 "$_lo_tmp" && mv -f "$_lo_tmp" "$_lo_file" \ - && [ -n "$_lo_digest" ] && [ -d "$_lo_root" ] && LAUNCH_FILE=$_lo_file \ + && chmod 0600 "$_lo_tmp" && mv -f "$_lo_tmp" "$_lo_file" && LAUNCH_FILE=$_lo_file \ || { rm -f "${_lo_tmp:-}" 2>/dev/null; _lo_notes="${_lo_notes:+$_lo_notes }LOADOUT_FALLBACK: could not publish the $_lo_name launch file; serving full (docs/troubleshooting.md#loadout-fallback)"; _lo_name=""; } fi if [ -z "$_lo_name" ] || [ -z "$_lo_root" ]; then - _lo_line="LOADOUT: full model=none source=model-flag reason=render-failed" _lo_root=$_lo_full + [ -n "$_lo_line" ] || _lo_notes="${_lo_notes:+$_lo_notes +}LOADOUT_FALLBACK: $(printf '%s\n' "$_lo_out" | tail -1 | tr -d '\000-\037' | cut -c1-200); serving full (docs/troubleshooting.md#loadout-fallback)" + _lo_line="LOADOUT: full model=none source=$MODEL_SOURCE reason=render-failed" _lo_root=$_lo_full fi LOADOUT_LINES="${_lo_notes:+$_lo_notes }$_lo_line diff --git a/docs/loadouts.md b/docs/loadouts.md index 0bd5e9de9..1013f8280 100644 --- a/docs/loadouts.md +++ b/docs/loadouts.md @@ -61,7 +61,7 @@ SKILL_ROOT: /home/user/.gstack/loadouts/lite-3f2a9c01d4e7 Read skills from `SKILL_ROOT` (`/gstack/SKILL.md` routes, `//SKILL.md` runs) and source the printed `GSTACK_LAUNCH_FILE`, which adds `GSTACK_LOADOUT_SKILL_ROOT`. For `full`, `SKILL_ROOT` is `~/.claude/skills` and the launch file is the install verdict's own. -The lite tree is published by `bin/gstack-modules install` under `/loadouts/-/`, beside the full install; the checkout and its committed SKILL.md files stay the full render. The digest covers the selection, the bound model, the full root, host, overlay, plan mode and the source tree (the git HEAD tree plus any uncommitted change), so an upgrade or a different model gets a new folder and an unchanged one is reused after its files are re-hashed against `loadout.lock.json`. Each folder is a complete skill tree: the rendered SKILL.md and section files plus every tracked runtime asset of each skill (templates, references, scripts). A published folder is never replaced; the newest three per loadout are kept. Two Capy threads with different models on one machine share one install verdict and get one launch file each (`.loadout--.env` for lite). Any failure serves `full` and prints `LOADOUT_FALLBACK`. +The lite tree is published by `bin/gstack-modules install` under `/loadouts/-/`, beside the full install; the checkout and its committed SKILL.md files stay the full render. The digest covers the selection, the bound model, the full root, host, overlay, plan mode and the source tree (the git HEAD tree plus any uncommitted change), so an upgrade or a different model gets a new folder and an unchanged one is reused after its files are re-hashed against `loadout.lock.json`. Each folder is a complete skill tree: the rendered SKILL.md and section files plus every tracked runtime asset of each skill (templates, references, scripts). A published folder is never replaced. Pruning removes only folders it published (a `-` name with an install lock) beyond the three most recently used per loadout, and never one used in the last day. When `--full-root` is not `~/.claude/skills`, the tree's command paths point at that install's `gstack` folder. Two Capy threads with different models on one machine share one install verdict and get one launch file each (`.loadout--.env` for lite). Any failure serves `full` and prints `LOADOUT_FALLBACK`. ### The start line carries the model diff --git a/scripts/modules-install.ts b/scripts/modules-install.ts index 14abd0cd7..b2823285e 100644 --- a/scripts/modules-install.ts +++ b/scripts/modules-install.ts @@ -17,12 +17,14 @@ * skill's own SKILL.md), add gstack/SKILL.md for the router, write * loadout.lock.json last, then rename the staging folder into place. A rename * that loses a race to the same identity reuses the winner; a published folder - * is never replaced. Folders of the same loadout beyond the newest KEEP are - * removed. + * is never replaced. Folders this installer published for the same loadout + * (name, digest and an install lock) beyond the newest KEEP by last use are + * removed once unused for PRUNE_GRACE_MS. */ import { spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { runGeneration } from './gen-skill-docs'; import { resolveModel } from './models'; @@ -32,6 +34,7 @@ import type { Host } from './resolvers/types'; const ROOT = path.resolve(import.meta.dir, '..'); const KEEP = 3; +const PRUNE_GRACE_MS = 24 * 60 * 60 * 1000; export interface InstallRequest { selection: ModuleSelection; @@ -121,12 +124,22 @@ function copyRuntimeAssets(source: string, stage: string): void { } } +function ownedInstall(dir: string): boolean { + try { + return JSON.parse(fs.readFileSync(path.join(dir, LOCK_FILE), 'utf-8')).kind === 'install'; + } catch { + return false; + } +} + function prune(store: string, loadout: string, keep: string): void { + const name = new RegExp(`^${loadout}-[0-9a-f]{12}$`); + const cutoff = Date.now() - PRUNE_GRACE_MS; const siblings = fs.readdirSync(store, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith(`${loadout}-`) && !e.name.includes('.stage-') && path.join(store, e.name) !== keep) + .filter(e => e.isDirectory() && name.test(e.name) && path.join(store, e.name) !== keep && ownedInstall(path.join(store, e.name))) .map(e => ({ dir: path.join(store, e.name), mtime: fs.statSync(path.join(store, e.name)).mtimeMs })) .sort((a, b) => b.mtime - a.mtime); - for (const { dir } of siblings.slice(KEEP - 1)) fs.rmSync(dir, { recursive: true, force: true }); + for (const { dir, mtime } of siblings.slice(KEEP - 1)) if (mtime < cutoff) fs.rmSync(dir, { recursive: true, force: true }); } export async function installLoadout(request: InstallRequest): Promise { @@ -161,7 +174,7 @@ export async function installLoadout(request: InstallRequest): Promise d.kind === 'error').map(d => d.message); if (result.exitCode !== 0 || errors.length) { throw new ModuleError('RENDER_FAILED', `gen-skill-docs failed: ${errors.join('; ') || 'exit 1'}`, 'fix the named template or module, then rerun; side effects: none, safe to retry'); diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 37374a282..f4d8fe540 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -50,7 +50,7 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) { const log = path.join(base, 'calls.log'); fs.mkdirSync(home, { recursive: true }); fs.mkdirSync(stub, { recursive: true }); - for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-install-verdict.sh', 'bin/gstack-node-runtime.sh']) { + for (const rel of ['bin/gstack-capy-install', 'bin/gstack-bun-version.sh', 'bin/gstack-claude-root.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-install-verdict.sh', 'bin/gstack-node-runtime.sh']) { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.copyFileSync(path.join(REPO, rel), path.join(root, rel)); fs.chmodSync(path.join(root, rel), fs.statSync(path.join(REPO, rel)).mode); @@ -530,7 +530,7 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd const f = fixture(); script(path.join(f.root, 'bin/gstack-modules'), [ 'echo "modules $*" >> "$CAPY_LOG"', - 'model="" full=""; while [ $# -gt 0 ]; do case "$1" in --model) model="$2"; shift ;; --full-root) full="$2"; shift ;; esac; shift; done', + 'model="${GSTACK_MODEL:-}" full=""; while [ $# -gt 0 ]; do case "$1" in --model) model="$2"; shift ;; --full-root) full="$2"; shift ;; esac; shift; done', 'case "$model" in', ' claude-opus-5-5) mkdir -p "$HOME/lite-0123456789ab"; echo "LOADOUT: lite model=claude-opus-5-5 source=model-flag digest=0123456789ab reused=no"; echo "SKILL_ROOT: $HOME/lite-0123456789ab" ;;', ' broken) echo "boom" >&2; exit 1 ;;', @@ -556,7 +556,7 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd expect(sonnet.stdout).toContain(`LOADOUT: full model=none source=model-flag reason=unmapped\nSKILL_ROOT: ${f.home}/.claude/skills\n`); expect(launchOf(sonnet.stdout)).toBe(base); const broken = run(f, ['--for', 'ship', '--model', 'broken']); - expect(broken.stdout).toContain(`LOADOUT: full model=none source=model-flag reason=render-failed\nSKILL_ROOT: ${f.home}/.claude/skills\n`); + expect(broken.stdout).toContain(`LOADOUT_FALLBACK: boom; serving full (docs/troubleshooting.md#loadout-fallback)\nLOADOUT: full model=none source=model-flag reason=render-failed\nSKILL_ROOT: ${f.home}/.claude/skills\n`); expect(launchOf(broken.stdout)).toBe(base); const plain = run(f, ['--for', 'ship']); expect(plain.stdout).not.toMatch(/^(LOADOUT|SKILL_ROOT):/m); @@ -564,6 +564,7 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd const fromEnv = run(f, ['--for', 'ship'], { GSTACK_MODEL: 'claude-opus-5-5' }); expect(fromEnv.status, fromEnv.stdout + fromEnv.stderr).toBe(0); expect(launchOf(fromEnv.stdout)).toBe(lite); + expect(f.calls().filter(l => l.startsWith('modules ')).at(-1)).toBe(`modules install --full-root ${f.home}/.claude/skills`); expect(verdicts(f)).toEqual([verdict]); expect(fs.existsSync(lite)).toBe(true); }); diff --git a/test/module-loadouts-install.test.ts b/test/module-loadouts-install.test.ts index 4c567b3ca..8eac52183 100644 --- a/test/module-loadouts-install.test.ts +++ b/test/module-loadouts-install.test.ts @@ -61,6 +61,8 @@ describe('gstack-modules install', () => { const landing = fs.readFileSync(path.join(root, 'land-and-deploy/SKILL.md'), 'utf-8'); const full = fs.readFileSync(path.join(ROOT, 'land-and-deploy/SKILL.md'), 'utf-8'); expect(landing.length).toBeLessThan(full.length); + expect(landing).toContain(`${FULL_ROOT}/gstack/bin/`); + expect(landing).not.toContain('~/.claude/skills/gstack/bin/'); expect(lock).toMatchObject({ kind: 'install', loadout: 'lite', loadout_model: 'claude-opus-5-5', full_root: FULL_ROOT }); expect(lock.excluded).toHaveLength(11); expect(lock.excluded).not.toContain('preamble:upgrade-check'); @@ -88,18 +90,31 @@ describe('gstack-modules install', () => { await expect(installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: FULL_ROOT, store })).rejects.toThrow(/RENDER_DEST_NOT_OWNED/); }, 240_000); - test('pruning keeps the three most recently used folders of a loadout: a reused folder counts as used', async () => { + test('pruning removes only its own folders past the three most recently used, and none used in the last day', async () => { const store = path.join(tmp, 'prune'); const install = (fullRoot: string) => installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot, store }); + const age = (dir: string, days: number) => { const t = new Date(Date.now() - days * 86_400_000); fs.utimesSync(dir, t, t); }; + const published = () => fs.readdirSync(store).filter(n => /^lite-[0-9a-f]{12}$/.test(n)); const inUse = await install('/r/0'); const oldest = await install('/r/1'); - await install('/r/2'); + const old = await install('/r/2'); + age(inUse.root, 3); + age(oldest.root, 3); + age(old.root, 2); + const foreign = path.join(store, 'lite-project'); + fs.mkdirSync(foreign); + fs.writeFileSync(path.join(foreign, 'notes.txt'), 'not gstack\n'); + age(foreign, 9); expect((await install('/r/0')).reused).toBe(true); const newest = await install('/r/3'); - expect(fs.existsSync(inUse.root)).toBe(true); - expect(fs.existsSync(newest.root)).toBe(true); + expect([inUse.root, old.root, newest.root].every(dir => fs.existsSync(dir))).toBe(true); expect(fs.existsSync(oldest.root)).toBe(false); - expect(fs.readdirSync(store).filter(n => n.startsWith('lite-'))).toHaveLength(3); + expect(fs.readFileSync(path.join(foreign, 'notes.txt'), 'utf8')).toBe('not gstack\n'); + await install('/r/4'); + await install('/r/5'); + expect(fs.existsSync(old.root)).toBe(false); + expect(fs.existsSync(inUse.root)).toBe(true); + expect(published()).toHaveLength(4); }, 300_000); test('the source tree identity changes with a commit and with uncommitted edits', () => {