fix: pre-landing review fixes (prune only owned, unused folders; full root from the canonical Claude install; fallback reason and source) (GSTA-161)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 14:33:05 +00:00
1 parent bb539ba4d5
commit 12bb281db2
5 files changed
+57 -21

No files matched your search

+14 -7
View File
@@ -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
+1 -1
View File
@@ -61,7 +61,7 @@ SKILL_ROOT: /home/user/.gstack/loadouts/lite-3f2a9c01d4e7
Read skills from `SKILL_ROOT` (`<root>/gstack/SKILL.md` routes, `<root>/<skill>/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 `<state root>/loadouts/<loadout>-<digest12>/`, 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 (`<verdict>.loadout-<name>-<digest>.env` for lite). Any failure serves `full` and prints `LOADOUT_FALLBACK`.
The lite tree is published by `bin/gstack-modules install` under `<state root>/loadouts/<loadout>-<digest12>/`, 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 `<loadout>-<digest12>` 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 (`<verdict>.loadout-<name>-<digest>.env` for lite). Any failure serves `full` and prints `LOADOUT_FALLBACK`.
### The start line carries the model
+18 -5
View File
@@ -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<InstallResult> {
@@ -161,7 +174,7 @@ export async function installLoadout(request: InstallRequest): Promise<InstallRe
}
const stage = `${final}.stage-${process.pid}-${randomBytes(4).toString('hex')}`;
try {
const result = await runGeneration({ host, outputRoot: stage, contentLinkRoot: final, installRoot: null, model, planMode, modules: selection });
const result = await runGeneration({ host, outputRoot: stage, contentLinkRoot: final, installRoot: request.fullRoot && request.fullRoot !== path.join(os.homedir(), '.claude/skills') ? path.join(request.fullRoot, 'gstack') : null, model, planMode, modules: selection });
const errors = result.diagnostics.filter(d => 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');
+4 -3
View File
@@ -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);
});
+20 -5
View File
@@ -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', () => {