mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
wave browse-7bde476eb22d: merge #3700 (caaa1419b0)
This commit is contained in:
commit
08413f84d3
28 files changed
+1045
-34
No files matched your search
@@ -206,6 +206,7 @@ the new defaults.
|
||||
| Validate periodic cases plus the weekly gate census on a branch | `gh workflow run evals-periodic.yml --ref <branch> -f include_gate_census=true` | CI runners + API spend | Pushed branch, `gh` with workflow rights |
|
||||
| Run the opt-in ML, gitleaks and Swift checks | `gh workflow run platform-qualification.yml --ref <branch>` | Free CI runners | Pushed branch, `gh` with workflow rights |
|
||||
| Render one module loadout as an eval arm ([loadouts](docs/loadouts.md)) | `bin/gstack-modules render --host claude --modules full,-section:<skill>/<id> --out-dir <dir>` (`--loadout full` for the control) | Free, about a second per host | Bun 1.4.2 |
|
||||
| See which loadout a model gets, or publish it as Capy would ([loadouts](docs/loadouts.md#how-gstack-picks-a-loadout)) | `bin/gstack-modules resolve --model <id>`; `bin/gstack-modules install --model <id> --full-root ~/.claude/skills --store <dir>` | Free, under a second | Bun 1.4.2 |
|
||||
| Look at past local eval runs | `bun run eval:list` / `eval:compare` / `eval:summary` | Free | Local eval history |
|
||||
| See a case's pass rate across recent weekly runs | `bun run eval:pass-rates --case <case-id>` | Free | `gh` with repo read access |
|
||||
| Measure one red case alone against the ship bar (MEETS 9/10) | `bun run scripts/ship-measure.ts measure --case <case-id> --round baseline` ([bar](docs/TESTING_INTERNALS.md#ship-measure)) | API spend for 10 trials (12 for behavior); asks above $2/trial, stops at $25/case | Same as paid coverage |
|
||||
|
||||
+43
-2
@@ -5,7 +5,7 @@
|
||||
# Usage:
|
||||
# gstack-capy-install [--for <skill> | --only <skill> | --require <id,id>] [--with-browser]
|
||||
# [--revision <ref>] [--project <path>] [--root <dir>]
|
||||
# [--check] [--min-revision <v>|unknown]
|
||||
# [--check] [--min-revision <v>|unknown] [--model <id>]
|
||||
#
|
||||
# Steps: clone or reuse the checkout under a lock (never reset, never pulled
|
||||
# here; the Capy skill bootstrapper fast-forwards below-minimum checkouts and
|
||||
@@ -42,6 +42,12 @@
|
||||
# mode-0600 file beside the verdict; callers and their children `. <file>` it,
|
||||
# never re-type it) and the doctor's trailer (`gstack: ok ...` or `gstack: fail ...`;
|
||||
# grep that, never pipe through tail).
|
||||
# --model <id>, else GSTACK_MODEL (GSTA-161, docs/loadouts.md), names the model this session runs:
|
||||
# bin/gstack-modules install picks its loadout, publishes a non-full one beside
|
||||
# the full Claude install and prints LOADOUT and SKILL_ROOT lines before the
|
||||
# launch lines; a non-full loadout gets its own launch file, keyed by loadout
|
||||
# name and digest, that adds GSTACK_LOADOUT_SKILL_ROOT. The verdict, its key and its
|
||||
# launch file are unchanged, and any failure serves full.
|
||||
# Exit: 0 ok, 1 a component failed, 2 usage, 3 preflight stopped before ./setup.
|
||||
set -u
|
||||
BASH_COMPAT=50
|
||||
@@ -50,7 +56,7 @@ SELF="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
ROOT="${GSTACK_CAPY_ROOT:-$SELF}"
|
||||
REPO="${GSTACK_CAPY_REPO:-https://github.com/garrytan/gstack.git}"
|
||||
DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md"
|
||||
FOR="" REQUIRE="" WITH_BROWSER=0 REVISION="" PROJECT="" CHECK_ONLY=0 MIN_REVISION="${GSTACK_CAPY_REMOTE_MIN:-}"
|
||||
MODEL="" FOR="" REQUIRE="" WITH_BROWSER=0 REVISION="" PROJECT="" CHECK_ONLY=0 MIN_REVISION="${GSTACK_CAPY_REMOTE_MIN:-}"
|
||||
usage() { sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; }
|
||||
need() { [ -n "${2:-}" ] || { echo "gstack-capy-install: $1 needs a value" >&2; usage >&2; exit 2; }; }
|
||||
while [ $# -gt 0 ]; do
|
||||
@@ -63,11 +69,14 @@ while [ $# -gt 0 ]; do
|
||||
--root) need "$1" "${2:-}"; ROOT="$2"; shift ;;
|
||||
--check) CHECK_ONLY=1 ;;
|
||||
--min-revision) need "$1" "${2:-}"; MIN_REVISION="$2"; shift ;;
|
||||
--model) need "$1" "${2:-}"; MODEL="$2"; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "gstack-capy-install: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
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"
|
||||
[ -z "$PROJECT" ] || { _given=$PROJECT; PROJECT=$(CDPATH= cd -- "$_given" 2>/dev/null && { git rev-parse --show-toplevel 2>/dev/null || pwd -P; }) || { echo "gstack-capy-install: --project takes a directory path; '$_given' is not a directory (pass the project checkout's path, not a repo slug)" >&2; exit 2; }; }
|
||||
@@ -713,9 +722,41 @@ fi
|
||||
# leaves that verdict's file in place.
|
||||
[ "$RC" -eq 0 ] || { [ "$PROFILE" -eq 1 ] && rm -rf "${VERDICT%.json}.profile" 2>/dev/null; verdict_kept || rm -f "$LAUNCH_FILE" 2>/dev/null; LAUNCH_FILE=""; }
|
||||
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=$(. "$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): ')
|
||||
_lo_name=$(printf '%s' "$_lo_line" | sed -n 's/^LOADOUT: \([a-z0-9-]*\) .*/\1/p')
|
||||
_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"
|
||||
[ -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" && 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
|
||||
[ -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
|
||||
SKILL_ROOT: $_lo_root"
|
||||
fi
|
||||
[ "$CHECK_ONLY" -eq 1 ] || [ "$PROFILE" -eq 1 ] || write_shim
|
||||
printf '%s\n' "$OUT" | grep -v '^gstack: '
|
||||
say "total: $(( $(now) - T0 ))s"
|
||||
[ -n "$LOADOUT_LINES" ] && printf '%s\n' "$LOADOUT_LINES"
|
||||
echo "GSTACK_LAUNCH: ${LAUNCH_DIR:+PATH=$LAUNCH_DIR:\$PATH }GSTACK_SESSION_KIND=unattended GSTACK_STATE_ROOT=$GSTACK_STATE_ROOT GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-1} GSTACK_CAPY=1 GSTACK_INSTALL_VERDICT=$VERDICT"
|
||||
[ -n "$LAUNCH_DIR" ] && echo "GSTACK_LAUNCH_PATH: $LAUNCH_DIR"
|
||||
[ -n "$LAUNCH_FILE" ] && echo "GSTACK_LAUNCH_FILE: $LAUNCH_FILE"
|
||||
|
||||
+10
-4
@@ -316,6 +316,7 @@ lookup_default() {
|
||||
skill_prefix) echo "false" ;;
|
||||
explain_level) echo "default" ;;
|
||||
plan_mode_preamble) echo "on" ;; # on | off — off drops the plan-mode preamble on the next ./setup (#3168)
|
||||
claude_loadout) echo "full" ;; # a modules/loadouts.json name — written by ./setup --loadout (docs/loadouts.md)
|
||||
codex_reviews) echo "enabled" ;;
|
||||
design_detector) echo "auto" ;; # auto | off — impeccable engine pre-pass in the design skills
|
||||
design_detector_install_prompted) echo "false" ;; # true once the user answered the one-time engine install offer with "never ask again"
|
||||
@@ -790,6 +791,10 @@ $_gstack_legacy_root"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [ "$KEY" = "claude_loadout" ] && ! printf '%s' "$VALUE" | grep -Eq '^[a-z0-9-]{1,40}$'; then
|
||||
echo "Warning: claude_loadout must be a loadout name such as lite or full. Using full." >&2
|
||||
VALUE="full"
|
||||
fi
|
||||
if [ "$KEY" = "plan_mode_preamble" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
|
||||
echo "Warning: plan_mode_preamble '$VALUE' not recognized. Valid values: on, off. Using on." >&2
|
||||
VALUE="on"
|
||||
@@ -1105,7 +1110,7 @@ $_gstack_legacy_root"
|
||||
echo "# ─── Active values (including defaults for unset keys) ───"
|
||||
report_root_disagreement
|
||||
for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \
|
||||
skill_prefix explain_level plan_mode_preamble \
|
||||
skill_prefix explain_level plan_mode_preamble claude_loadout \
|
||||
codex_reviews gstack_contributor skip_eng_review workspace_root \
|
||||
artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \
|
||||
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \
|
||||
@@ -1130,7 +1135,7 @@ $_gstack_legacy_root"
|
||||
defaults)
|
||||
echo "# gstack-config defaults"
|
||||
for KEY in proactive routing_declined telemetry auto_upgrade update_check update_channel \
|
||||
skill_prefix explain_level plan_mode_preamble \
|
||||
skill_prefix explain_level plan_mode_preamble claude_loadout \
|
||||
codex_reviews gstack_contributor skip_eng_review workspace_root \
|
||||
artifacts_sync_mode artifacts_sync_mode_prompted artifacts_sync_removals plan_tune_hooks \
|
||||
timeline_stop_hook design_detector design_detector_install_prompted memorable_recall \
|
||||
@@ -1163,6 +1168,7 @@ $_gstack_legacy_root"
|
||||
. "$SCRIPT_DIR/gstack-render-claude.sh" 2>/dev/null || { echo "$0: $SCRIPT_DIR/gstack-render-claude.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; }
|
||||
_PLAN_MODE_ARG=""
|
||||
[ "$("$0" get plan_mode_preamble 2>/dev/null || true)" != off ] || _PLAN_MODE_ARG="--plan-mode=off"
|
||||
gstack_claude_loadout_arg "$0"
|
||||
DETECT_BIN="$SCRIPT_DIR/gstack-gbrain-detect"
|
||||
DETECTION_FILE="$STATE_DIR/gbrain-detection.json"
|
||||
mkdir -p "$STATE_DIR"
|
||||
@@ -1231,7 +1237,7 @@ $_gstack_legacy_root"
|
||||
gstack_claude_overlay "$INSTALL_DIR" "$0"
|
||||
RENDER_TMP="$RENDER_DIR.tmp.$$"
|
||||
rm -rf "$RENDER_TMP"
|
||||
if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} >/dev/null 2>&1 ); then
|
||||
if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ${_LOADOUT_ARG:+"$_LOADOUT_ARG"} >/dev/null 2>&1 ); then
|
||||
gstack_render_lock "$RENDER_DIR"
|
||||
_swap_in_render "$RENDER_DIR" "$RENDER_TMP"
|
||||
gstack_render_unlock "$RENDER_DIR"
|
||||
@@ -1257,7 +1263,7 @@ $_gstack_legacy_root"
|
||||
# brain blocks, so removing gbrain keeps the chosen overlay.
|
||||
INSTALL_DIR="$HOME/.claude/skills/gstack"
|
||||
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$STATE_DIR/render/claude}"
|
||||
if { [ -d "$RENDER_DIR" ] || "$0" has claude_overlay_model || [ -n "$_PLAN_MODE_ARG" ]; } \
|
||||
if { [ -d "$RENDER_DIR" ] || "$0" has claude_overlay_model || [ -n "$_PLAN_MODE_ARG" ] || [ -n "$_LOADOUT_ARG" ]; } \
|
||||
&& [ -d "$INSTALL_DIR" ] && [ ! -L "$INSTALL_DIR" ] && [ -f "$INSTALL_DIR/VERSION" ] && [ -f "$INSTALL_DIR/package.json" ]; then
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo "Skip: bun not on PATH — the Claude render at $RENDER_DIR was not refreshed. Install bun, then re-run 'gstack-config gbrain-refresh'."
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
# plan_mode_preamble is off (#3168); empty or unset means on. The committed
|
||||
# files carry the preamble, so with it off the default install is rendered.
|
||||
#
|
||||
# Callers set _LOADOUT_ARG with gstack_claude_loadout_arg (claude_loadout,
|
||||
# written by ./setup --loadout; GSTA-161): a non-full loadout is rendered
|
||||
# into the render dir like a pinned overlay, and the checkout stays full.
|
||||
#
|
||||
# Callers define `_swap_in_render` (setup and bin/gstack-config each keep
|
||||
# their tested copy) and may define `log` and `bun_cmd`; plain echo and bun
|
||||
# are used otherwise.
|
||||
@@ -56,6 +60,23 @@ gstack_claude_overlay() {
|
||||
_gstack_render_say " note: $_GSTACK_OVERLAY_NOTE"
|
||||
}
|
||||
|
||||
# gstack_claude_loadout_arg CONFIG_BIN — set _LOADOUT_ARG to --loadout=<name>
|
||||
# for a saved non-full claude_loadout, else empty. A malformed value is
|
||||
# ignored with a note, so the full skills are served.
|
||||
gstack_claude_loadout_arg() {
|
||||
local CONFIG_BIN="$1" value
|
||||
_LOADOUT_ARG=""
|
||||
value="$("$CONFIG_BIN" get claude_loadout 2>/dev/null || true)"
|
||||
case "$value" in
|
||||
''|full) return 0 ;;
|
||||
esac
|
||||
if printf '%s' "$value" | grep -Eq '^[a-z0-9-]{1,40}$'; then
|
||||
_LOADOUT_ARG="--loadout=$value"
|
||||
else
|
||||
_gstack_render_say " note: claude_loadout is not a loadout name; serving the full skills. Fix: ./setup --loadout full"
|
||||
fi
|
||||
}
|
||||
|
||||
# gstack_claude_overlay_field RECORD KEY — one field of an activation record.
|
||||
gstack_claude_overlay_field() {
|
||||
[ -f "$1" ] || return 0
|
||||
@@ -94,10 +115,11 @@ gstack_render_unlock() {
|
||||
gstack_claude_render_action() {
|
||||
local render_dir="$1" gbrain="$2" previous
|
||||
if [ "$gbrain" = ok ]; then _GSTACK_RENDER_ACTION=user; return 0; fi
|
||||
if [ "$_GSTACK_OVERLAY" != claude ] || [ -n "${_PLAN_MODE_ARG:-}" ]; then _GSTACK_RENDER_ACTION=plain; return 0; fi
|
||||
if [ "$_GSTACK_OVERLAY" != claude ] || [ -n "${_PLAN_MODE_ARG:-}" ] || [ -n "${_LOADOUT_ARG:-}" ]; then _GSTACK_RENDER_ACTION=plain; return 0; fi
|
||||
previous="$(gstack_claude_overlay_field "$render_dir.overlay" overlay)"
|
||||
if [ "$gbrain" = unknown ] && [ "${previous:-claude}" = claude ] \
|
||||
&& [ -z "$(gstack_claude_overlay_field "$render_dir.overlay" plan_mode)" ]; then
|
||||
&& [ -z "$(gstack_claude_overlay_field "$render_dir.overlay" plan_mode)" ] \
|
||||
&& [ -z "$(gstack_claude_overlay_field "$render_dir.overlay" loadout)" ]; then
|
||||
_GSTACK_RENDER_ACTION=keep
|
||||
else
|
||||
_GSTACK_RENDER_ACTION=remove
|
||||
@@ -110,8 +132,8 @@ gstack_claude_render_plain() {
|
||||
local src="$1" RENDER_DIR="$2" RENDER_TMP out rc=0
|
||||
RENDER_TMP="$RENDER_DIR.tmp.$$"
|
||||
rm -rf "$RENDER_TMP"
|
||||
_gstack_render_say "Rendering Claude skills with the $_GSTACK_OVERLAY overlay${_PLAN_MODE_ARG:+ and no plan-mode preamble} into $RENDER_DIR (source checkout stays clean)..."
|
||||
out="$(cd "$src" && _gstack_render_bun run gen:skill-docs --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} 2>&1)" || rc=$?
|
||||
_gstack_render_say "Rendering Claude skills with the $_GSTACK_OVERLAY overlay${_PLAN_MODE_ARG:+ and no plan-mode preamble}${_LOADOUT_ARG:+ and the ${_LOADOUT_ARG#--loadout=} loadout} into $RENDER_DIR (source checkout stays clean)..."
|
||||
out="$(cd "$src" && _gstack_render_bun run gen:skill-docs --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ${_LOADOUT_ARG:+"$_LOADOUT_ARG"} 2>&1)" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
rm -rf "$RENDER_TMP"
|
||||
printf '%s\n' "$out" | tail -3
|
||||
@@ -162,6 +184,7 @@ gstack_claude_render_settle() {
|
||||
printf 'overlay=%s\nsource=%s\nmodel=%s\nversion=%s\ngbrain=%s\nrender=%s\n' \
|
||||
"$served" "$_GSTACK_OVERLAY_SOURCE" "$_GSTACK_OVERLAY_ID" "${version:-unknown}" "$gbrain" "$render_kind"
|
||||
[ "$render_kind" != user ] || [ -z "${_PLAN_MODE_ARG:-}" ] || printf 'plan_mode=off\n'
|
||||
[ "$render_kind" != user ] || [ -z "${_LOADOUT_ARG:-}" ] || printf 'loadout=%s\n' "${_LOADOUT_ARG#--loadout=}"
|
||||
} > "$record.tmp.$$" && mv "$record.tmp.$$" "$record"
|
||||
fi
|
||||
gstack_render_unlock "$render_dir"
|
||||
|
||||
+32
-1
@@ -41,14 +41,18 @@ SKILL_NAME=""
|
||||
MODEL_OVERLAY="none"
|
||||
PARENT_PID=""
|
||||
BRAIN_HEALTH="no"
|
||||
LOADOUT="" LOADOUT_MODEL="" FULL_ROOT=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--skill) SKILL_NAME="$2"; shift 2 ;;
|
||||
--model) MODEL_OVERLAY="$2"; shift 2 ;;
|
||||
--parent-pid) PARENT_PID="$2"; shift 2 ;;
|
||||
--brain-health) BRAIN_HEALTH="yes"; shift ;;
|
||||
--loadout) LOADOUT="$2"; shift 2 ;;
|
||||
--loadout-model) LOADOUT_MODEL="$2"; shift 2 ;;
|
||||
--full-root) FULL_ROOT="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
echo 'Usage: gstack-skill-start --skill NAME [--model OVERLAY] [--parent-pid PID] [--brain-health]'
|
||||
echo 'Usage: gstack-skill-start --skill NAME [--model OVERLAY] [--parent-pid PID] [--brain-health] [--loadout NAME --loadout-model ID --full-root DIR]'
|
||||
echo 'Starts a skill session and prints its STATUS lines. --help starts nothing.'
|
||||
exit 0 ;;
|
||||
*) shift ;;
|
||||
@@ -326,6 +330,33 @@ if [ -d ".claude/skills/gstack" ] && [ ! -L ".claude/skills/gstack" ]; then
|
||||
fi
|
||||
echo "VENDORED_GSTACK: $_VENDORED"
|
||||
echo "MODEL_OVERLAY: $MODEL_OVERLAY"
|
||||
# Loadout (GSTA-161, docs/loadouts.md): a render chosen for one model bakes
|
||||
# its loadout, that model id and the full install root into the start command.
|
||||
# Every value is re-validated before it is echoed, and GSTACK_MODEL (the
|
||||
# running model, when the harness sets it) is only compared, never echoed
|
||||
# unless it is a plain model id.
|
||||
_LO_MODEL_RE='^[a-z0-9][a-z0-9._-]{0,79}$'
|
||||
if [ -n "$LOADOUT" ]; then
|
||||
[[ "$LOADOUT" =~ ^[a-z0-9-]{1,40}$ ]] || LOADOUT="invalid"
|
||||
[[ "$LOADOUT_MODEL" =~ $_LO_MODEL_RE ]] || LOADOUT_MODEL=""
|
||||
[[ "$FULL_ROOT" =~ ^/[A-Za-z0-9_.@+/-]*$ ]] || FULL_ROOT=""
|
||||
[[ "$SKILL_NAME" =~ ^[a-z0-9-]{1,60}$ ]] || FULL_ROOT=""
|
||||
if [ -z "$LOADOUT_MODEL" ] || [ -z "$FULL_ROOT" ]; then
|
||||
echo "LOADOUT: $LOADOUT model=unknown: the start line is malformed; read the full SKILL.md for this skill instead of this file."
|
||||
else
|
||||
_lo_full="$FULL_ROOT/$SKILL_NAME/SKILL.md"
|
||||
echo "LOADOUT: $LOADOUT model=$LOADOUT_MODEL full_root=$FULL_ROOT"
|
||||
echo "LOADOUT_RULE: this file was rendered for $LOADOUT_MODEL. If you are not running $LOADOUT_MODEL, read $_lo_full now and follow it instead of this file."
|
||||
if [ -n "${GSTACK_MODEL:-}" ]; then
|
||||
_lo_run=$(printf '%s' "$GSTACK_MODEL" | tr '[:upper:]' '[:lower:]' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/\[[^]]*\]$//' -e 's/[[:space:]]*$//' -e 's#^[a-z0-9-]*/##')
|
||||
if ! [[ "$_lo_run" =~ $_LO_MODEL_RE ]]; then
|
||||
echo "LOADOUT_MISMATCH: $LOADOUT_MODEL vs an invalid GSTACK_MODEL: read $_lo_full now and follow it instead of this file."
|
||||
elif [ "$_lo_run" != "$LOADOUT_MODEL" ] && ! [[ "${_lo_run#"$LOADOUT_MODEL"-}" =~ ^([0-9]{8}|latest)$ && "$_lo_run" == "$LOADOUT_MODEL"-* ]]; then
|
||||
echo "LOADOUT_MISMATCH: $LOADOUT_MODEL vs $_lo_run: read $_lo_full now and follow it instead of this file."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Plan-mode hint for skills that branch on plan-mode state. Detected best-effort
|
||||
# from CLAUDE_PLAN_FILE (set by the harness when plan mode is active); "inactive"
|
||||
# is the safe default (file+execute pipeline).
|
||||
|
||||
@@ -30,7 +30,7 @@ With `GSTACK_CAPY=1`, a workflow started in an unattended or spawned session che
|
||||
|
||||
For Capy running GPT-6 Astra, read `${CODEX_HOME:-$HOME/.codex}/skills/gstack/SKILL.md` for routing, then `${CODEX_HOME:-$HOME/.codex}/skills/gstack-<skill>/SKILL.md` for the selected workflow. These are generated for GPT-6 Astra, not the Claude source files.
|
||||
|
||||
For Claude Code, read `~/.claude/skills/gstack/SKILL.md` for routing and `~/.claude/skills/<skill>/SKILL.md` for the selected workflow. When launching a Claude Code session, tell it to load gstack and name the intended workflow. For Capy running a Claude model, the Claude instruction files can be read directly with the Capy adaptations below; that does not turn Capy into the Claude Code harness.
|
||||
For Claude Code, read `~/.claude/skills/gstack/SKILL.md` for routing and `~/.claude/skills/<skill>/SKILL.md` for the selected workflow. When launching a Claude Code session, tell it to load gstack and name the intended workflow. For Capy running a Claude model, the Claude instruction files can be read directly with the Capy adaptations below; that does not turn Capy into the Claude Code harness. Run `scripts/install.sh` with `GSTACK_MODEL=<your model id>` in front (an installer that predates it ignores the variable); when it prints `SKILL_ROOT: <dir>`, read skills from `<dir>` instead (`docs/loadouts.md`).
|
||||
|
||||
If no specific skill was requested, use the router's task-to-skill mapping. Read only the selected workflow and its referenced files. For other models, use the closest applicable instruction profile without claiming a model-specific profile was generated or evaluated for them.
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
**Capy sessions on Claude Opus 5.5 can read a smaller set of gstack skills (GSTA-161).** Before, every model read the full shared preamble on every skill. Now Opus 5.5 gets the `lite` loadout: the same skills without eleven coaching sections of the preamble, about 10.7 KB less on land-and-deploy. Every other model, and any model gstack is not told about, still gets the full skills, and the checkout's committed SKILL.md files stay full.
|
||||
|
||||
### Added
|
||||
- **`lite` loadout and a model map** in `modules/loadouts.json`. `lite` drops the voice, writing-style, completeness, confusion, evidence, context-health, question-tuning, search-before-building, repo-mode, brain-health and context-recovery pieces and keeps the upgrade check and brain sync. `models` maps only `claude-opus-5-5` (and its dated snapshots) to it; a point release or any other suffix gets full.
|
||||
- **Explicit model detection** (`lib/model-detect.ts`): an explicit loadout, then `--model`, then `GSTACK_MODEL`, else full. An unmapped, invalid or unreadable model id serves full and never errors (`LOADOUT_INVALID_MODEL` names an id that is not a plain model id).
|
||||
- **`gstack-capy-install --model <id>`** (passed through `scripts/install.sh`), or `GSTACK_MODEL=<id>` in its environment, which the Capy skill uses because an older installer ignores it: prints `LOADOUT` and `SKILL_ROOT` lines and, for lite, writes a second launch file that adds `GSTACK_LOADOUT_SKILL_ROOT`. The install verdict and its key do not change.
|
||||
- **`gstack-modules install` and `resolve`**: `install` publishes the lite tree under `<state root>/loadouts/<loadout>-<digest>/` as a complete skill tree (rendered files plus every tracked runtime asset), keyed by the selection, model, full root, host and source tree, reused only after its files re-hash against the lock, and never replaced in place. A failure serves full with `LOADOUT_FALLBACK`. `render --bind-model <id> --full-root <dir>` renders the same start lines for eval arms.
|
||||
- **`./setup --loadout <name>`** for Claude Code: saved as `claude_loadout` and rendered through setup's own Claude render into the untracked render dir; `--loadout full` returns to the committed files.
|
||||
- **The start line carries the model.** A lite skill's start command names its model and the full install root, so `gstack-skill-start` prints `LOADOUT` and `LOADOUT_RULE` lines, plus `LOADOUT_MISMATCH` when `GSTACK_MODEL` names another model, telling that session to read the full SKILL.md instead.
|
||||
|
||||
### Tests
|
||||
- `test/model-detect.test.ts`, `test/module-loadouts-install.test.ts`, `test/loadout-setup.test.ts`, and new cases in `test/module-loadouts.test.ts` and `test/gstack-capy-install.test.ts` cover the mapping and its near misses, hostile model ids, the start-line rules, two models on one machine, runtime assets from `SKILL_ROOT`, identity changes, concurrent publishers, and `./setup --loadout`.
|
||||
@@ -94,7 +94,7 @@ gstack/
|
||||
│ ├── test/ # Integration tests
|
||||
│ └── dist/ # Compiled binary
|
||||
├── agents-digest/ # Committed 2KB instruction-tier rules digest (gstack-AGENTS.md) for rules-reading hosts
|
||||
├── modules/ # loadouts.json (+ loadouts.schema.json): model rungs, pinned modules and named loadouts; read by scripts/modules.ts, rendered by bin/gstack-modules (docs/loadouts.md)
|
||||
├── modules/ # loadouts.json (+ loadouts.schema.json): model rungs, the model map, pinned modules and named loadouts; read by scripts/modules.ts and lib/model-detect.ts, rendered by bin/gstack-modules (docs/loadouts.md)
|
||||
├── extension/ # Chrome extension (side panel + activity feed + CSS inspector)
|
||||
├── lib/ # Shared libraries (aside-render.ts = local-HTML rendering, Aside first, engine fallback; design-catalog.ts = the typed design anti-pattern catalog every design skill renders from; design-lint/ = the static and render tiers behind bin/gstack-design-lint, ported from hallmark's sloplint; design-detect-contract.ts = detector sentinel vocabulary; design-md.ts = open DESIGN.md reader/writer; dom-dump-script.ts + generated dom-dump.js = rendered-DOM dump for the detector; review-evidence.ts = review-start receipt binding, computed freshness and shared-code snapshot eligibility; frontend-scope.ts; claude-bin.ts, error-handling.ts, worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, version-source.ts, code-intelligence/)
|
||||
│ └── diagram-render/ # Vendored mermaid + excalidraw runtimes, built into one offline bundle the renderer loads
|
||||
|
||||
@@ -472,6 +472,13 @@ make <dir> writable, or set GSTACK_STATE_ROOT to a writable directory
|
||||
(LAUNCH_FILE_UNWRITABLE)`, and a failing run prints no `GSTACK_LAUNCH_FILE` line
|
||||
and removes the file an earlier passing run left beside the verdict it rewrites
|
||||
(a profile check that keeps a fuller ok verdict leaves that verdict's file).
|
||||
With `--model <id>`, or `GSTACK_MODEL=<id>` in its environment (what the Capy
|
||||
skill passes), an ok run other than `--for review-voice` also prints `LOADOUT: …`
|
||||
and `SKILL_ROOT: <dir>` just before `GSTACK_LAUNCH`. For a loadout other than
|
||||
`full`, `GSTACK_LAUNCH_FILE` then names `<verdict file without .json>.loadout-<name>-<digest>.env`,
|
||||
a copy of the verdict's launch file that adds `GSTACK_LOADOUT_SKILL_ROOT`; the
|
||||
verdict and its own launch file do not change, and any failure prints
|
||||
`LOADOUT_FALLBACK` and serves `full` ([loadouts](loadouts.md#capy)).
|
||||
`. <that file>` at the start of a shell applies them without ever printing them,
|
||||
which is the only way to hand them to a child agent: pass the path, never the
|
||||
values. None of the assignments is a secret, but during GSTA-96's unattended
|
||||
|
||||
+49
-4
@@ -1,8 +1,8 @@
|
||||
# Module loadouts
|
||||
|
||||
gstack can render a smaller or larger set of its instructions for a given model. Each toggleable piece is a **module** with an id. A **loadout** is a named set of modules, and `modules/loadouts.json` maps each model **rung** to a loadout. Today every rung loads `full`, which is the same render gstack has always shipped. A loadout changes only when the shared skill eval (GSTA-2) measures a smaller one that matches `full` within its preregistered margin.
|
||||
gstack can render a smaller or larger set of its instructions for a given model. Each toggleable piece is a **module** with an id. A **loadout** is a named set of modules, and `modules/loadouts.json` maps each model **rung** to a loadout. Every rung loads `full`, which is the same render gstack has always shipped. A loadout changes only when a preregistered eval measures a smaller one that matches `full` within its margin.
|
||||
|
||||
Install-time model detection, which picks a loadout for the model you run, ships with the first loadout that is not `full`. Until then, loadouts are an eval tool: `bin/gstack-modules render` produces any loadout as a byte-exact tree with a lock file.
|
||||
One model has its own loadout: Claude Opus 5.5 gets `lite` ([below](#lite-on-claude-opus-55)). Every other model, and any model gstack is not told about, gets `full`. `bin/gstack-modules render` also produces any loadout as a byte-exact tree with a lock file for evals.
|
||||
|
||||
## Terms
|
||||
|
||||
@@ -12,6 +12,7 @@ Install-time model detection, which picks a loadout for the model you run, ships
|
||||
| Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. |
|
||||
| Rung | How much scaffolding a model gets: `frontier`, `strong`, `mid`, `light`, or `unknown`. Model ids are matched after lowercasing and stripping a `provider/` prefix (`anthropic/claude-haiku-5-5` is `mid`). Rungs are separate from the frontier/smart tiers in `lib/model-catalog.ts`, which choose which model runs a role. |
|
||||
| Loadout | `{ base, exclude, include, preamble_tier_max, note }`. `full` (include `*`) is always present. An unknown model gets `default.unknown`, which is `full`. |
|
||||
| Model map | `models` in `modules/loadouts.json`: an exact model id to a loadout, checked before rungs. The id also matches its dated snapshot (`-YYYYMMDD`) and `-latest`, never a point release or any other suffix (`claude-opus-5-5` matches `claude-opus-5-5-20261101`, not `claude-opus-5-50` or `claude-opus-5-5-1`). |
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -22,9 +23,11 @@ bin/gstack-modules render --host claude --loadout full --out-dir ~/arms/control
|
||||
bin/gstack-modules render --host claude \
|
||||
--modules 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design' \
|
||||
--out-dir ~/arms/oh-lean
|
||||
bin/gstack-modules resolve --model claude-opus-5-5 # the loadout an install would serve, and why
|
||||
bin/gstack-modules install --full-root "$HOME/.claude/skills" --model claude-opus-5-5 # publish it and print SKILL_ROOT
|
||||
```
|
||||
|
||||
`render` writes the whole host tree into a staging directory beside `--out-dir`. It checks that no rendered file still points at an excluded module, writes `loadout.lock.json` last (loadout name or `adhoc`, the normalized spec and its sha256, host, model, every excluded id, and the sha256 and byte count of every file), then swaps the result into place. A failure leaves an earlier render at `--out-dir` untouched. Claude section links name `--out-dir`, so point the eval harness at that directory. Pass the same `--model`, `--install-root` and `--plan-mode` the install under test uses, so the arms differ only by the modules. Codex renders default to the host's generic `gpt` overlay, while `./setup` uses `gpt-6-astra`.
|
||||
`render` writes the whole host tree into a staging directory beside `--out-dir`. It checks that no rendered file still points at an excluded module, writes `loadout.lock.json` last (loadout name or `adhoc`, the normalized spec and its sha256, host, model, every excluded id, and the sha256 and byte count of every file), then swaps the result into place. A failure leaves an earlier render at `--out-dir` untouched. Claude section links name `--out-dir`, so point the eval harness at that directory. Pass the same `--model`, `--install-root` and `--plan-mode` the install under test uses, so the arms differ only by the modules. Codex renders default to the host's generic `gpt` overlay, while `./setup` uses `gpt-6-astra`. `--bind-model <id> --full-root <abs>` (both or neither, on a loadout that excludes something) bakes the start lines an install for that model serves ([below](#the-start-line-carries-the-model)).
|
||||
|
||||
`bun run gen:skill-docs --loadout <name> --out-dir <dir>` and `--modules <spec> --out-dir <dir>` apply the same filter to a generation run without the staging, dangling-reference check or lock file; a selection with exclusions and no `--out-dir` is refused with `MODULE_SPEC_INVALID`, because the committed tree is always the `full` render. A non-`full` render adds `<!-- loadout: <name> -->` to each SKILL.md header.
|
||||
|
||||
@@ -35,6 +38,48 @@ bin/gstack-modules render --host claude \
|
||||
3. Excluding a section removes its STOP pointer, its Section-index row and its file. The step becomes `> Not loaded in the <loadout> loadout: skip <trigger>. Do not offer it, and treat it as not run.` Skeleton text that belongs to the section, such as office-hours' Phase 3.5 offer, sits inside `{{IF_SECTION:<id>}}…{{/IF_SECTION}}` in the template and goes with it.
|
||||
4. The autoplan runner (`gstack-autoplan`) reads review methodology from the source tree, not from a rendered loadout, so autoplan-runner arms always measure `full` methodology.
|
||||
|
||||
## Lite on Claude Opus 5.5
|
||||
|
||||
`lite` is `full` minus eleven coaching pieces of the shared preamble: voice, writing style, completeness, confusion, evidence, context health, question tuning, search before building, repo mode, brain health and context recovery. It keeps every pinned piece, the upgrade check (the PROACTIVE and skill-prefix rules) and brain sync (the privacy stop-gate). Only `claude-opus-5-5` maps to it, because only Opus 5.5 has been measured: office-hours showed no loss on Opus 5.5 (GSTA-161 preamble run), and the land-and-deploy confirmation (GSTA-161, PR 1b) compared full, lite and no gstack on the same tasks. Haiku 5.5 leaned slightly against a larger cut on the same office-hours run, so small models stay on `full`. Two skills point at a preamble section lite drops: office-hours and plan-ceo-review name ETHOS.md through the "Search Before Building" section, so under lite they find ETHOS.md in the gstack checkout instead.
|
||||
|
||||
### How gstack picks a loadout
|
||||
|
||||
gstack never guesses the model. It uses, in order:
|
||||
|
||||
1. An explicit loadout: `./setup --loadout <name>` on Claude Code, or `bin/gstack-modules install --loadout <name>`. It is served as asked and bound to no model.
|
||||
2. An explicit model id: `--model <id>` on the Capy installer (`scripts/install.sh --model <id>`, which passes it to `bin/gstack-capy-install`), else `GSTACK_MODEL`, which the installer also reads.
|
||||
3. Nothing: `full`.
|
||||
|
||||
A model id is lowercased, its `provider/` prefix and a trailing `[…]` are dropped, and it must then be a plain id (`^[a-z0-9][a-z0-9._-]{0,79}$`). An unmapped id serves `full`; an id that is not plain also serves `full` and prints `LOADOUT_INVALID_MODEL`. Nothing reads Claude Code settings or an inherited loadout variable, so a Sonnet subagent of an Opus session gets `full` unless its own install names Opus.
|
||||
|
||||
### Capy
|
||||
|
||||
The Capy skill passes the thread's model id in the environment: `GSTACK_MODEL=<id> bash scripts/install.sh --check --for <skill>`. The variable, unlike `--model`, is safe on a checkout whose installer predates loadouts: that installer ignores it instead of refusing an unknown argument. The installer prints two lines before the launch lines:
|
||||
|
||||
```
|
||||
LOADOUT: lite model=claude-opus-5-5 source=GSTACK_MODEL digest=3f2a9c01d4e7 reused=yes
|
||||
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. 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
|
||||
|
||||
A lite tree chosen for a model bakes that model and the full root into each skill's start command, so `gstack-skill-start` prints:
|
||||
|
||||
```
|
||||
LOADOUT: lite model=claude-opus-5-5 full_root=/home/user/.claude/skills
|
||||
LOADOUT_RULE: this file was rendered for claude-opus-5-5. If you are not running claude-opus-5-5, read /home/user/.claude/skills/qa/SKILL.md now and follow it instead of this file.
|
||||
```
|
||||
|
||||
When `GSTACK_MODEL` is set to a different model, it adds `LOADOUT_MISMATCH`. A preamble already read cannot be unread, so the remedy is always to read the full file. An explicit loadout (`./setup --loadout lite`) carries no model and prints neither line.
|
||||
|
||||
### Claude Code
|
||||
|
||||
`./setup --loadout lite` saves `claude_loadout` and renders the Claude skills into the untracked render dir (`<state root>/render/claude`) through setup's own render, the same path a pinned overlay or `plan_mode_preamble=off` takes, so later upgrades and `gstack-config gbrain-refresh` keep it. `./setup --loadout full` returns to the committed files. A plain `./setup` never selects lite.
|
||||
|
||||
## Changing a loadout
|
||||
|
||||
Add or edit an entry under `loadouts` and cite the GSTA-2 run (preregistration, margin, per-rung result) in its `note` and in the PR body. A model-specific loadout that wins a preregistered comparison is allowed even if it breaks the expected nesting (light ⊇ mid ⊇ strong). Committed SKILL.md files are always the `full` render, so the parity suite, the SKILL.md size guard, the size ledger and the catalog and context budgets keep measuring `full`.
|
||||
Add or edit an entry under `loadouts` (and `models`, for a model-specific one) and cite the measurement (preregistration, margin, per-rung result) in its `note` and in the PR body. A model-specific loadout that wins a preregistered comparison is allowed even if it breaks the expected nesting (light ⊇ mid ⊇ strong). Committed SKILL.md files are always the `full` render, so the parity suite, the SKILL.md size guard, the size ledger and the catalog and context budgets keep measuring `full`.
|
||||
+16
-1
@@ -6210,7 +6210,7 @@ QM registration does not work yet. QM's skill-pack import clones the whole repos
|
||||
|
||||
## Module loadouts (gstack-modules)
|
||||
|
||||
These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modules` (guide: [loadouts.md](loadouts.md)). Each one exits 2 before anything is written, and none falls back to the `full` loadout: an eval arm that silently rendered `full` would measure the control.
|
||||
These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modules` (guide: [loadouts.md](loadouts.md)). Each one exits 2 before anything is written, and none falls back to the `full` loadout: an eval arm that silently rendered `full` would measure the control. The exceptions are the install-time notices `LOADOUT_INVALID_MODEL`, `LOADOUT_MISMATCH` and `LOADOUT_FALLBACK` (from `bin/gstack-modules install`, the Capy installer and `gstack-skill-start`): an install never fails on them and serves `full` instead.
|
||||
|
||||
<a id="loadouts-invalid"></a>
|
||||
### `LOADOUTS_INVALID: modules/loadouts.json …`
|
||||
@@ -6222,6 +6222,21 @@ These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modul
|
||||
|
||||
**Meaning.** `--loadout` or the first token of `--modules` names no loadout in `modules/loadouts.json`. **Fix.** Use a name from the `valid:` list (`bin/gstack-modules list` prints them); `full` is always present.
|
||||
|
||||
<a id="loadout-invalid-model"></a>
|
||||
### `LOADOUT_INVALID_MODEL: "<id>" is not a plain model id; using full`
|
||||
|
||||
**Meaning.** The model id given to the installer (`--model`) or in `GSTACK_MODEL` is not a plain id after normalizing (lowercase letters, digits, `.`, `_` and `-`, at most 80 characters), so it never reaches a launch file or a start line. The full skills are served; nothing failed. **Fix.** Pass the model id as your model list names it, for example `--model claude-opus-5-5` or `--model anthropic/claude-opus-5-5` ([loadouts](loadouts.md#how-gstack-picks-a-loadout)).
|
||||
|
||||
<a id="loadout-mismatch"></a>
|
||||
### `LOADOUT_MISMATCH: <baked id> vs <running id>: read <full root>/<skill>/SKILL.md now`
|
||||
|
||||
**Meaning.** The skill you opened is the lite render made for one model, and `GSTACK_MODEL` says this session runs another. Lite is measured only on the baked model. **Fix.** Read the named full SKILL.md and follow it instead of the lite one. To stop it recurring, run the installer with this session's model (`scripts/install.sh --model <id>`) and read skills from the `SKILL_ROOT` it prints.
|
||||
|
||||
<a id="loadout-fallback"></a>
|
||||
### `LOADOUT_FALLBACK: <reason>; serving full`
|
||||
|
||||
**Meaning.** A lite tree could not be published (a render or dangling-reference failure, an unwritable state root, or a published folder whose files no longer match its `loadout.lock.json`), so the installer served the full skills and printed `SKILL_ROOT` for them. Nothing else changed. **Fix.** Fix the named reason; for a folder whose files changed, delete `<state root>/loadouts/<loadout>-<digest>` and rerun the installer, which publishes it again.
|
||||
|
||||
<a id="module-spec-invalid"></a>
|
||||
### `MODULE_SPEC_INVALID: …`
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Model detection for loadouts (GSTA-161, PR 1b): which loadout an install
|
||||
* serves, from explicit inputs only.
|
||||
*
|
||||
* Order: (1) an explicit loadout name (`--loadout`), served as asked and bound
|
||||
* to no model; (2) an explicit model id (`--model`), else `GSTACK_MODEL`;
|
||||
* (3) nothing, which is full. A model id is normalized (trim, lowercase, a
|
||||
* trailing `[…]` and a `provider/` prefix dropped) and must match MODEL_ID_RE
|
||||
* before it can reach generated shell or a launch file. An invalid, unmapped
|
||||
* or unreadable input always resolves to full and never errors; only an
|
||||
* unknown explicit loadout name is refused. No Claude settings, overlay
|
||||
* preference or inherited loadout variable is read.
|
||||
* Guide: docs/loadouts.md.
|
||||
*/
|
||||
import { FULL, MODEL_ID_RE, ModuleError, loadLoadouts, loadoutForModel, normalizeModelId, rungFor, type LoadoutsManifest } from '../scripts/modules';
|
||||
|
||||
export interface LoadoutResolution {
|
||||
loadout: string;
|
||||
source: 'loadout-flag' | 'model-flag' | 'GSTACK_MODEL' | 'default';
|
||||
/** The model an automatic selection is bound to (the matched manifest.models key, so a dated snapshot binds to its base id); null for an explicit or default selection. */
|
||||
model: string | null;
|
||||
/** Why a given model resolved to full. */
|
||||
reason?: 'unmapped' | 'invalid' | 'manifest-unreadable' | 'render-failed';
|
||||
/** A LOADOUT_INVALID_MODEL line to print, when the model id was refused. */
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export function resolveLoadout(input: { loadout?: string; model?: string; env?: Record<string, string | undefined>; manifest?: LoadoutsManifest }): LoadoutResolution {
|
||||
const env = input.env ?? process.env;
|
||||
if (input.loadout !== undefined) {
|
||||
const manifest = input.manifest ?? loadLoadouts();
|
||||
if (!Object.hasOwn(manifest.loadouts, input.loadout)) {
|
||||
throw new ModuleError('LOADOUT_UNKNOWN', `${JSON.stringify(input.loadout.slice(0, 40))} is not a loadout (valid: ${Object.keys(manifest.loadouts).sort().join(', ')})`, 'gstack-modules list');
|
||||
}
|
||||
return { loadout: input.loadout, source: 'loadout-flag', model: null };
|
||||
}
|
||||
const raw = input.model ?? env.GSTACK_MODEL;
|
||||
if (raw === undefined || raw.trim() === '') return { loadout: FULL, source: 'default', model: null };
|
||||
const source = input.model !== undefined ? 'model-flag' : 'GSTACK_MODEL';
|
||||
const id = normalizeModelId(raw);
|
||||
if (!MODEL_ID_RE.test(id)) {
|
||||
const shown = JSON.stringify(raw.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 40));
|
||||
return { loadout: FULL, source, model: null, reason: 'invalid', notice: `LOADOUT_INVALID_MODEL: ${shown} is not a plain model id; using full (docs/troubleshooting.md#loadout-invalid-model)` };
|
||||
}
|
||||
let manifest: LoadoutsManifest;
|
||||
try { manifest = input.manifest ?? loadLoadouts(); } catch { return { loadout: FULL, source, model: null, reason: 'manifest-unreadable' }; }
|
||||
const mapped = loadoutForModel(manifest, id);
|
||||
const loadout = mapped?.loadout ?? manifest.default?.[rungFor(manifest, id)] ?? FULL;
|
||||
if (loadout === FULL || !Object.hasOwn(manifest.loadouts, loadout)) return { loadout: FULL, source, model: null, reason: 'unmapped' };
|
||||
return { loadout, source, model: mapped?.key ?? id };
|
||||
}
|
||||
|
||||
/** One printable line: `LOADOUT: <name> model=<id|none> source=<s>[ reason=<r>]`. */
|
||||
export function describeResolution(r: LoadoutResolution): string {
|
||||
return `LOADOUT: ${r.loadout} model=${r.model ?? 'none'} source=${r.source}${r.reason ? ` reason=${r.reason}` : ''}`;
|
||||
}
|
||||
@@ -50,8 +50,28 @@
|
||||
"full": {
|
||||
"include": "*",
|
||||
"note": "Every module: today's render."
|
||||
},
|
||||
"lite": {
|
||||
"base": "full",
|
||||
"exclude": [
|
||||
"preamble:voice",
|
||||
"preamble:writing-style",
|
||||
"preamble:completeness",
|
||||
"preamble:confusion",
|
||||
"preamble:evidence",
|
||||
"preamble:context-health",
|
||||
"preamble:question-tuning",
|
||||
"preamble:search-before-building",
|
||||
"preamble:repo-mode",
|
||||
"preamble:brain-health",
|
||||
"preamble:context-recovery"
|
||||
],
|
||||
"note": "Opus 5.5 only (GSTA-161). Drops eleven coaching preamble pieces and keeps upgrade-check and brain-sync. Evidence: the GSTA-161 office-hours preamble run (handoffs/GSTA-161/preamble-measurement.md) and the three-arm land-and-deploy confirmation cited in docs/loadouts.md."
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"claude-opus-5-5": "lite"
|
||||
},
|
||||
"default": {
|
||||
"frontier": "full",
|
||||
"strong": "full",
|
||||
|
||||
@@ -127,6 +127,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"description": "Exact model id (a dated -YYYYMMDD snapshot or -latest also matches) to loadout name. Checked before rungs; anything unmatched uses the rung default.",
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-z0-9][a-z0-9._-]{0,79}$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+$"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
+49
-3
@@ -5,6 +5,14 @@
|
||||
* gstack-modules validate
|
||||
* gstack-modules render --host <h> (--loadout <name> | --modules <base>,-id,+id) --out-dir <d>
|
||||
* [--install-root <abs>] [--model <id>] [--plan-mode on|off]
|
||||
* [--bind-model <id> --full-root <abs>]
|
||||
* gstack-modules resolve [--model <id>] [--loadout <name>]
|
||||
* gstack-modules install --full-root <abs> [--model <id>] [--loadout <name>] [--store <dir>] [--plan-mode on|off]
|
||||
*
|
||||
* resolve and install pick the loadout through lib/model-detect.ts. install
|
||||
* prints `LOADOUT: …` and `SKILL_ROOT: <dir>`: the full root for full, else
|
||||
* the published lite tree (scripts/modules-install.ts). A lite render that
|
||||
* fails prints LOADOUT_FALLBACK and serves full, exit 0.
|
||||
*
|
||||
* Exit 0 on success, 2 on a refused input (ModuleError, printed with its code,
|
||||
* fix and docs anchor), 1 on an unexpected failure.
|
||||
@@ -14,8 +22,11 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { resolveHostArg } from '../hosts/index';
|
||||
import { resolveModel } from './models';
|
||||
import { LOADOUTS_FILE, ModuleError, loadLoadouts, moduleCatalog, selectModules, validateLoadouts } from './modules';
|
||||
import { LOADOUTS_FILE, MODEL_ID_RE, ModuleError, loadLoadouts, moduleCatalog, normalizeModelId, selectModules, validateLoadouts } from './modules';
|
||||
import { renderLoadout } from './modules-render';
|
||||
import { installLoadout } from './modules-install';
|
||||
import { describeResolution, resolveLoadout } from '../lib/model-detect';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
import type { Host } from './resolvers/types';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
@@ -24,7 +35,14 @@ const USAGE = `Usage:
|
||||
gstack-modules validate check ${LOADOUTS_FILE} against the catalog
|
||||
gstack-modules render --host <h> (--loadout <name> | --modules <base>,-id,+id) --out-dir <d>
|
||||
[--install-root <abs>] [--model <id>] [--plan-mode on|off]
|
||||
render one loadout and write loadout.lock.json
|
||||
[--bind-model <id> --full-root <abs>]
|
||||
render one loadout and write loadout.lock.json;
|
||||
--bind-model bakes the start lines an install
|
||||
for that model serves
|
||||
gstack-modules resolve [--model <id>] [--loadout <name>]
|
||||
the loadout an install would serve, and why
|
||||
gstack-modules install --full-root <abs> [--model <id>] [--loadout <name>] [--store <dir>] [--plan-mode on|off]
|
||||
publish that loadout and print SKILL_ROOT
|
||||
Guide: docs/loadouts.md`;
|
||||
|
||||
export interface CliResult { code: number; stdout: string; stderr: string }
|
||||
@@ -74,10 +92,38 @@ export async function modulesMain(args: string[]): Promise<CliResult> {
|
||||
if (planMode !== undefined && planMode !== 'on' && planMode !== 'off') throw new ModuleError('MODULE_SPEC_INVALID', '--plan-mode takes on or off', 'drop it, or pass --plan-mode off');
|
||||
const installRoot = flag(rest, '--install-root');
|
||||
if (installRoot !== undefined && !/^\/[A-Za-z0-9_.@+\/-]*$/.test(installRoot)) throw new ModuleError('MODULE_SPEC_INVALID', '--install-root must be a plain absolute path', 'pass the absolute install root setup would use');
|
||||
const selection = selectModules({ loadout, modules });
|
||||
const bindModel = flag(rest, '--bind-model');
|
||||
const fullRoot = flag(rest, '--full-root');
|
||||
const selected = selectModules({ loadout, modules });
|
||||
if ((bindModel === undefined) !== (fullRoot === undefined)) throw new ModuleError('MODULE_SPEC_INVALID', '--bind-model and --full-root go together', 'pass both, or neither');
|
||||
if (bindModel !== undefined && !selected) throw new ModuleError('MODULE_SPEC_INVALID', '--bind-model needs a loadout that excludes something', 'drop --bind-model for the full loadout');
|
||||
if (bindModel !== undefined && !MODEL_ID_RE.test(normalizeModelId(bindModel))) throw new ModuleError('MODULE_SPEC_INVALID', '--bind-model is not a plain model id', 'pass an id such as claude-opus-5-5');
|
||||
if (fullRoot !== undefined && !/^\/[A-Za-z0-9_.@+\/-]*$/.test(fullRoot)) throw new ModuleError('MODULE_SPEC_INVALID', '--full-root must be a plain absolute path', 'pass the full install root, for example --full-root "$HOME/.claude/skills"');
|
||||
const selection = selected && bindModel !== undefined ? { ...selected, boundModel: normalizeModelId(bindModel), fullRoot } : selected;
|
||||
const lock = await renderLoadout({ host, selection, outDir, installRoot: installRoot ?? null, model, planMode: planMode !== 'off' });
|
||||
return { code: 0, stdout: `RENDERED: loadout=${lock.loadout} host=${host} files=${lock.files.length} bytes=${lock.total_bytes} excluded=${lock.excluded.length} lock=${path.join(path.resolve(outDir), 'loadout.lock.json')}\n`, stderr: '' };
|
||||
}
|
||||
if (command === 'resolve' || command === 'install') {
|
||||
const resolution = resolveLoadout({ loadout: flag(rest, '--loadout'), model: flag(rest, '--model') });
|
||||
const notice = resolution.notice ? `${resolution.notice}\n` : '';
|
||||
if (command === 'resolve') return { code: 0, stdout: describeResolution(resolution) + '\n', stderr: notice };
|
||||
const fullRoot = flag(rest, '--full-root');
|
||||
if (!fullRoot || !/^\/[A-Za-z0-9_.@+\/-]*$/.test(fullRoot)) throw new ModuleError('MODULE_SPEC_INVALID', 'install needs --full-root <plain absolute path>', 'pass the full install root, for example --full-root "$HOME/.claude/skills"');
|
||||
const planMode = flag(rest, '--plan-mode');
|
||||
if (planMode !== undefined && planMode !== 'on' && planMode !== 'off') throw new ModuleError('MODULE_SPEC_INVALID', '--plan-mode takes on or off', 'drop it, or pass --plan-mode off');
|
||||
const full = (line: string) => ({ code: 0, stdout: `${line}\nSKILL_ROOT: ${fullRoot}\n`, stderr: notice });
|
||||
if (resolution.loadout === 'full') return full(describeResolution(resolution));
|
||||
try {
|
||||
const selection = selectModules({ loadout: resolution.loadout });
|
||||
if (!selection) return full(describeResolution(resolution));
|
||||
const store = flag(rest, '--store') ?? path.join(resolveStateRoot(), 'loadouts');
|
||||
const result = await installLoadout({ selection, boundModel: resolution.model, fullRoot, store, planMode: planMode !== 'off' });
|
||||
return { code: 0, stdout: `${describeResolution(resolution)} digest=${result.lock.identity_sha256.slice(0, 12)} reused=${result.reused ? 'yes' : 'no'}\nSKILL_ROOT: ${result.root}\n`, stderr: notice };
|
||||
} catch (error) {
|
||||
const why = error instanceof ModuleError ? `${error.code}: ${error.message}` : (error as Error).message;
|
||||
return { code: 0, stdout: `${describeResolution({ ...resolution, loadout: 'full', model: null, reason: 'render-failed' })}\nSKILL_ROOT: ${fullRoot}\n`, stderr: `${notice}LOADOUT_FALLBACK: ${why.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 400)}; serving full (docs/troubleshooting.md#loadout-fallback)\n` };
|
||||
}
|
||||
}
|
||||
return { code: 2, stdout: '', stderr: `Unknown command ${JSON.stringify(command.slice(0, 40))}.\n${USAGE}\n` };
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleError) return { code: 2, stdout: '', stderr: `${error.code}: ${error.message}\n` };
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* gstack-modules install (GSTA-161, PR 1b): a non-full loadout published as a
|
||||
* complete, immutable skill tree beside the full install, for Capy's
|
||||
* SKILL_ROOT. The checkout itself always stays the full render.
|
||||
*
|
||||
* Identity: sha256 over the effective selection (loadout, sorted exclusions,
|
||||
* tier cap), the bound model and full root baked into each start line, the
|
||||
* host, the overlay model, plan mode and the source tree (the git HEAD tree,
|
||||
* plus a digest of uncommitted and untracked changes; a source git cannot read
|
||||
* gets a one-off nonce, so it never reuses a folder). Folder: <store>/<loadout>-<identity12>.
|
||||
* A folder is reused only when its lock names the same full identity and every
|
||||
* listed file still hashes to its recorded sha256.
|
||||
*
|
||||
* Publish: render into a staging folder (section links already name the final
|
||||
* folder), refuse dangling references, copy the source's tracked runtime assets
|
||||
* the render lacks (templates, references, scripts; never *.tmpl, test/ or the
|
||||
* 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 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';
|
||||
import { ModuleError, type ModuleSelection } from './modules';
|
||||
import { LOCK_FILE, danglingReferences } from './modules-render';
|
||||
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;
|
||||
/** Validated model id the selection was made for, or null for an explicit loadout. */
|
||||
boundModel: string | null;
|
||||
fullRoot: string;
|
||||
store: string;
|
||||
host?: Host;
|
||||
planMode?: boolean;
|
||||
}
|
||||
|
||||
export interface InstallLock {
|
||||
schema_version: 1;
|
||||
kind: 'install';
|
||||
identity_sha256: string;
|
||||
identity: Record<string, unknown>;
|
||||
loadout: string;
|
||||
loadout_model: string | null;
|
||||
full_root: string;
|
||||
gstack_version: string;
|
||||
excluded: string[];
|
||||
total_bytes: number;
|
||||
files: Array<{ path: string; bytes: number; sha256: string }>;
|
||||
}
|
||||
|
||||
export interface InstallResult { root: string; lock: InstallLock; reused: boolean }
|
||||
|
||||
const sha256 = (data: string | Buffer) => createHash('sha256').update(data).digest('hex');
|
||||
|
||||
function git(root: string, args: string[]): string | null {
|
||||
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf-8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
|
||||
return result.status === 0 ? result.stdout : null;
|
||||
}
|
||||
|
||||
/** The git HEAD tree, plus a digest of uncommitted and untracked changes when there are any; a nonce when git cannot say. */
|
||||
export function sourceTree(root: string): string {
|
||||
const tree = git(root, ['rev-parse', 'HEAD^{tree}'])?.trim();
|
||||
const diff = git(root, ['diff', '--no-ext-diff', '--binary', 'HEAD']);
|
||||
const untracked = git(root, ['ls-files', '-z', '--others', '--exclude-standard']);
|
||||
if (!tree || diff === null || untracked === null) return `unpinned-${randomBytes(8).toString('hex')}`;
|
||||
if (!diff && !untracked) return tree;
|
||||
const extra = untracked.split('\0').filter(Boolean).sort().map(rel => {
|
||||
try { return `${rel}:${sha256(fs.readFileSync(path.join(root, rel)))}`; } catch { return `${rel}:unreadable`; }
|
||||
});
|
||||
return `${tree}+${sha256(diff + '\0' + extra.join('\n')).slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function walk(dir: string, base = dir): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return walk(full, base);
|
||||
return entry.isFile() ? [path.relative(base, full).split(path.sep).join('/')] : [];
|
||||
}).sort();
|
||||
}
|
||||
|
||||
function hashFiles(dir: string) {
|
||||
return walk(dir).filter(rel => rel !== LOCK_FILE).map(rel => {
|
||||
const bytes = fs.readFileSync(path.join(dir, rel));
|
||||
return { path: rel, bytes: bytes.length, sha256: sha256(bytes) };
|
||||
});
|
||||
}
|
||||
|
||||
function intact(dir: string, identity: string): InstallLock | null {
|
||||
try {
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(dir, LOCK_FILE), 'utf-8')) as InstallLock;
|
||||
if (lock.kind !== 'install' || lock.identity_sha256 !== identity) return null;
|
||||
const actual = new Map(hashFiles(dir).map(f => [f.path, f.sha256]));
|
||||
if (actual.size !== lock.files.length || lock.files.some(f => actual.get(f.path) !== f.sha256)) return null;
|
||||
return lock;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy each tracked runtime asset of a rendered skill directory that the render did not write. */
|
||||
function copyRuntimeAssets(source: string, stage: string): void {
|
||||
const listed = git(source, ['ls-files', '-z']);
|
||||
if (listed === null) throw new ModuleError('RENDER_FAILED', `cannot list the tracked files of ${source}`, 'install from a git checkout of gstack');
|
||||
for (const rel of listed.split('\0').filter(Boolean)) {
|
||||
const [dir, ...rest] = rel.split('/');
|
||||
if (!rest.length || !fs.existsSync(path.join(stage, dir, 'SKILL.md'))) continue;
|
||||
if (rel.endsWith('.tmpl') || rel === `${dir}/SKILL.md` || rest.some(seg => seg === 'test' || seg === 'node_modules' || seg === 'dist')) continue;
|
||||
const to = path.join(stage, rel);
|
||||
if (fs.existsSync(to)) continue;
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.copyFileSync(path.join(source, rel), to);
|
||||
}
|
||||
}
|
||||
|
||||
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() && 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, mtime } of siblings.slice(KEEP - 1)) if (mtime < cutoff) fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function installLoadout(request: InstallRequest): Promise<InstallResult> {
|
||||
const source = ROOT;
|
||||
const host = request.host ?? 'claude';
|
||||
const planMode = request.planMode ?? true;
|
||||
const model = request.boundModel ? resolveModel(request.boundModel) : null;
|
||||
const selection: ModuleSelection = { ...request.selection, boundModel: request.boundModel ?? undefined, fullRoot: request.boundModel ? request.fullRoot : undefined };
|
||||
const identity = {
|
||||
loadout: selection.name,
|
||||
excluded: [...selection.excluded].sort(),
|
||||
preamble_tier_max: selection.preambleTierMax ?? null,
|
||||
loadout_model: request.boundModel,
|
||||
full_root: request.fullRoot,
|
||||
host,
|
||||
overlay_model: model,
|
||||
plan_mode: planMode,
|
||||
source_tree: sourceTree(source),
|
||||
};
|
||||
const identitySha = sha256(JSON.stringify(identity));
|
||||
const store = path.resolve(request.store);
|
||||
const final = path.join(store, `${selection.name}-${identitySha.slice(0, 12)}`);
|
||||
fs.mkdirSync(store, { recursive: true });
|
||||
const existing = intact(final, identitySha);
|
||||
if (existing) {
|
||||
const used = new Date();
|
||||
fs.utimesSync(final, used, used);
|
||||
return { root: final, lock: existing, reused: true };
|
||||
}
|
||||
if (fs.existsSync(final)) {
|
||||
throw new ModuleError('RENDER_DEST_NOT_OWNED', `${final} exists but its ${LOCK_FILE} does not match this render`, `remove ${final} and rerun; nothing else was changed`);
|
||||
}
|
||||
const stage = `${final}.stage-${process.pid}-${randomBytes(4).toString('hex')}`;
|
||||
try {
|
||||
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');
|
||||
}
|
||||
const dangling = danglingReferences(stage, selection);
|
||||
if (dangling.length) throw new ModuleError('MODULE_EXCLUSION_UNSAFE', dangling.slice(0, 5).join('; '), 'exclude the referencing module too, or guard the reference with {{IF_SECTION:id}}');
|
||||
copyRuntimeAssets(source, stage);
|
||||
fs.mkdirSync(path.join(stage, 'gstack'), { recursive: true });
|
||||
fs.copyFileSync(path.join(stage, 'SKILL.md'), path.join(stage, 'gstack', 'SKILL.md'));
|
||||
const files = hashFiles(stage);
|
||||
const lock: InstallLock = {
|
||||
schema_version: 1,
|
||||
kind: 'install',
|
||||
identity_sha256: identitySha,
|
||||
identity,
|
||||
loadout: selection.name,
|
||||
loadout_model: request.boundModel,
|
||||
full_root: request.fullRoot,
|
||||
gstack_version: fs.readFileSync(path.join(source, 'VERSION'), 'utf-8').trim(),
|
||||
excluded: identity.excluded,
|
||||
total_bytes: files.reduce((sum, f) => sum + f.bytes, 0),
|
||||
files,
|
||||
};
|
||||
fs.writeFileSync(path.join(stage, LOCK_FILE), JSON.stringify(lock, null, 2) + '\n');
|
||||
try {
|
||||
fs.renameSync(stage, final);
|
||||
} catch (error) {
|
||||
const winner = intact(final, identitySha);
|
||||
if (!winner) throw error;
|
||||
return { root: final, lock: winner, reused: true };
|
||||
}
|
||||
prune(store, selection.name, final);
|
||||
return { root: final, lock, reused: false };
|
||||
} finally {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ export interface LoadoutLock {
|
||||
model: string | null;
|
||||
install_root: string | null;
|
||||
plan_mode: boolean;
|
||||
/** The model a --bind-model render bakes into its start lines, and the full root it names. */
|
||||
loadout_model: string | null;
|
||||
full_root: string | null;
|
||||
gstack_version: string;
|
||||
excluded: string[];
|
||||
total_bytes: number;
|
||||
@@ -152,6 +155,8 @@ export async function renderLoadout(request: RenderRequest): Promise<LoadoutLock
|
||||
model: request.model ?? getHostConfig(request.host).defaultModel ?? null,
|
||||
install_root: request.installRoot ?? null,
|
||||
plan_mode: request.planMode ?? true,
|
||||
loadout_model: request.selection?.boundModel ?? null,
|
||||
full_root: request.selection?.fullRoot ?? null,
|
||||
gstack_version: fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf-8').trim(),
|
||||
excluded: [...(request.selection?.excluded ?? [])].sort(),
|
||||
total_bytes: files.reduce((sum, f) => sum + f.bytes, 0),
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface LoadoutsManifest {
|
||||
rungs: Record<(typeof RUNGS)[number], string[]>;
|
||||
pinned: string[];
|
||||
loadouts: Record<string, LoadoutDef>;
|
||||
/** Exact model id to loadout, checked before rungs (a dated snapshot or -latest also matches). */
|
||||
models?: Record<string, string>;
|
||||
default: Record<Rung, string>;
|
||||
}
|
||||
|
||||
@@ -106,6 +108,18 @@ export function normalizeModelId(raw: string): string {
|
||||
return raw.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').trim().replace(/^[a-z0-9-]+\//, '');
|
||||
}
|
||||
|
||||
/** Model ids that may reach generated shell or a launch file; anything else resolves to full. */
|
||||
export const MODEL_ID_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/;
|
||||
|
||||
/** An exact id, or the same id plus a dated snapshot (-YYYYMMDD) or -latest; a point release never matches. */
|
||||
export const modelKeyMatches = (key: string, id: string) => id === key || (id.startsWith(`${key}-`) && /^(\d{8}|latest)$/.test(id.slice(key.length + 1)));
|
||||
|
||||
/** The manifest.models key a model id matches and the loadout it names, or undefined when none matches. */
|
||||
export function loadoutForModel(manifest: LoadoutsManifest, id: string): { key: string; loadout: string } | undefined {
|
||||
const key = Object.keys(manifest.models ?? {}).find(k => modelKeyMatches(k, id));
|
||||
return key === undefined ? undefined : { key, loadout: manifest.models![key] };
|
||||
}
|
||||
|
||||
const matches = (pattern: string, id: string) => (pattern.endsWith('*') ? id.startsWith(pattern.slice(0, -1)) : id === pattern);
|
||||
|
||||
/** The rung a model id belongs to: aliases first, then the first rung whose pattern matches. */
|
||||
@@ -148,6 +162,13 @@ export function validateLoadouts(manifest: LoadoutsManifest, catalog: ModuleEntr
|
||||
seen.add(at);
|
||||
}
|
||||
}
|
||||
const modelKeys = Object.keys(manifest.models ?? {});
|
||||
for (const [key, name] of Object.entries(manifest.models ?? {})) {
|
||||
if (!MODEL_ID_RE.test(key)) problems.push(`models key ${clamp(key)} must be an exact model id matching ${MODEL_ID_RE.source}`);
|
||||
if (!has(name)) problems.push(`models.${key} names no loadout (${clamp(String(name))})`);
|
||||
const overlap = modelKeys.find(other => other !== key && modelKeyMatches(other, key));
|
||||
if (overlap) problems.push(`models key ${key} is also matched by ${overlap}`);
|
||||
}
|
||||
for (const rung of [...RUNGS, 'unknown'] as const) {
|
||||
if (!has(manifest.default?.[rung])) problems.push(`default.${rung} names no loadout (${clamp(String(manifest.default?.[rung]))})`);
|
||||
}
|
||||
|
||||
@@ -23,11 +23,28 @@ import { skillStartLines } from '../runtime-root';
|
||||
* sessions refuse a command named by a variable, a `[ -x ] ||` fallback, or a
|
||||
* trailing `|| echo`. A missing helper is covered by the degraded-mode prose.
|
||||
*/
|
||||
/**
|
||||
* A loadout chosen for one model (GSTA-161) bakes its name, that model and
|
||||
* the full install root into the start line, so gstack-skill-start can tell
|
||||
* any other model to read the full SKILL.md. An explicit or ad-hoc selection
|
||||
* is bound to no model and adds nothing. The values are validated literals;
|
||||
* nothing from the environment reaches here.
|
||||
*/
|
||||
function loadoutStartFlags(ctx: TemplateContext): string {
|
||||
const filter = ctx.modules;
|
||||
if (!filter?.boundModel) return '';
|
||||
if (!/^[a-z0-9-]{1,40}$/.test(filter.name)) throw new Error(`loadout name ${JSON.stringify(filter.name)} cannot be baked into a start line`);
|
||||
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(filter.boundModel)) throw new Error(`model id ${JSON.stringify(filter.boundModel.slice(0, 80))} cannot be baked into a start line`);
|
||||
if (!filter.fullRoot || !/^\/[A-Za-z0-9_.@+\/-]*$/.test(filter.fullRoot)) throw new Error('a model-bound loadout needs a plain absolute fullRoot');
|
||||
return ` --loadout '${filter.name}' --loadout-model '${filter.boundModel}' --full-root '${filter.fullRoot}'`;
|
||||
}
|
||||
|
||||
export function generatePreambleBash(ctx: TemplateContext): string {
|
||||
const hostConfig = getHostConfig(ctx.host);
|
||||
const start = hostConfig.usesEnvVars ? skillStartLines(ctx) : null;
|
||||
const runtimeRoot = start ? `${start.lookup}\n` : '';
|
||||
const brainHealthFlag = ctx.host === 'gbrain' || ctx.host === 'hermes' ? ' --brain-health' : '';
|
||||
const loadoutFlags = loadoutStartFlags(ctx);
|
||||
const startCommand = start ? start.command : `${ctx.paths.binDir}/gstack-skill-start`;
|
||||
|
||||
const entry = ['plan-design-review', 'plan-eng-review'].includes(ctx.skillName)
|
||||
@@ -39,7 +56,7 @@ export function generatePreambleBash(ctx: TemplateContext): string {
|
||||
return `${entry}
|
||||
|
||||
\`\`\`bash
|
||||
${runtimeRoot}${startCommand} --skill "${ctx.skillName}" --model "${ctx.model ?? 'none'}"${brainHealthFlag}
|
||||
${runtimeRoot}${startCommand} --skill "${ctx.skillName}" --model "${ctx.model ?? 'none'}"${loadoutFlags}${brainHealthFlag}
|
||||
\`\`\`
|
||||
|
||||
Read the echoed \`KEY: value\` STATUS lines — they drive every preamble rule
|
||||
|
||||
@@ -140,6 +140,10 @@ export interface ModuleFilter {
|
||||
pinned: ReadonlySet<string>;
|
||||
/** Unpinned preamble pieces render as if the skill's preamble-tier were at most this. */
|
||||
preambleTierMax?: number;
|
||||
/** The model an automatic selection was made for; the start line tells any other model to read fullRoot instead. */
|
||||
boundModel?: string;
|
||||
/** Absolute root of the full install that holds <skill>/SKILL.md. */
|
||||
fullRoot?: string;
|
||||
}
|
||||
|
||||
/** Resolver function signature. args is populated for parameterized placeholders like {{INVOKE_SKILL:name}}. */
|
||||
|
||||
@@ -61,6 +61,11 @@ Model selection:
|
||||
every later setup, upgrade and gbrain-refresh. Without it
|
||||
the generic claude overlay is used; --claude-model claude
|
||||
returns to it.
|
||||
--loadout <name> Claude skill loadout from modules/loadouts.json (for
|
||||
example lite). Saved as claude_loadout and rendered into
|
||||
the untracked Claude render dir by every later setup,
|
||||
upgrade and gbrain-refresh; the checkout stays full.
|
||||
--loadout full returns to the full skills (docs/loadouts.md).
|
||||
|
||||
Examples:
|
||||
./setup # solo install for Claude Code
|
||||
@@ -182,13 +187,14 @@ MODEL_OVERRIDE=""
|
||||
MODEL_OVERRIDE_SET=0
|
||||
CLAUDE_MODEL_ARG=""
|
||||
CLAUDE_MODEL_SET=0
|
||||
LOADOUT_SET=0
|
||||
REFRESH_REGISTERED=0
|
||||
HOST_FLAG_SET=0
|
||||
HARNESS_AUTO=1; [ "${GSTACK_HARNESS_AUTO:-1}" = 0 ] && HARNESS_AUTO=0
|
||||
# Reject unknown options before anything is written, with the closest flag.
|
||||
_setup_unknown_option() {
|
||||
local bad="$1" next="${2:-}" best="" best_d=99 flag d value=""
|
||||
for flag in --host --model --claude-model --global --local --prefix --no-prefix --team --no-team \
|
||||
for flag in --host --model --claude-model --loadout --global --local --prefix --no-prefix --team --no-team \
|
||||
--plan-tune-hooks --no-plan-tune-hooks --timeline-stop-hook --no-timeline-stop-hook \
|
||||
--refresh-registered --status --no-browser --no-harness-auto --quiet --help; do
|
||||
d="$(awk -v a="${bad%%=*}" -v b="$flag" 'BEGIN {
|
||||
@@ -204,7 +210,7 @@ _setup_unknown_option() {
|
||||
if [ "$d" -lt "$best_d" ]; then best_d="$d"; best="$flag"; fi
|
||||
done
|
||||
echo "Unknown option: $bad — nothing was installed or changed." >&2
|
||||
case "$best" in --host|--model|--claude-model) case "$next" in ''|-*) ;; *) value=" $next" ;; esac ;; esac
|
||||
case "$best" in --host|--model|--claude-model|--loadout) case "$next" in ''|-*) ;; *) value=" $next" ;; esac ;; esac
|
||||
if [ "$best_d" -le 3 ]; then
|
||||
echo "Did you mean: ./setup $best$value" >&2
|
||||
fi
|
||||
@@ -218,6 +224,8 @@ while [ $# -gt 0 ]; do
|
||||
--model=*) MODEL_OVERRIDE="${1#--model=}"; MODEL_OVERRIDE_SET=1; shift ;;
|
||||
--claude-model) [ -z "$2" ] && echo "Missing value for --claude-model (a Claude model ID such as claude-opus-4-8, or claude)" >&2 && exit 1; CLAUDE_MODEL_ARG="$2"; CLAUDE_MODEL_SET=1; shift 2 ;;
|
||||
--claude-model=*) CLAUDE_MODEL_ARG="${1#--claude-model=}"; [ -z "$CLAUDE_MODEL_ARG" ] && echo "Missing value for --claude-model (a Claude model ID such as claude-opus-4-8, or claude)" >&2 && exit 1; CLAUDE_MODEL_SET=1; shift ;;
|
||||
--loadout) [ -z "$2" ] && echo "Missing value for --loadout (a loadout from modules/loadouts.json, such as lite or full)" >&2 && exit 1; LOADOUT_ARG="$2"; LOADOUT_SET=1; shift 2 ;;
|
||||
--loadout=*) LOADOUT_ARG="${1#--loadout=}"; [ -z "$LOADOUT_ARG" ] && echo "Missing value for --loadout (a loadout from modules/loadouts.json, such as lite or full)" >&2 && exit 1; LOADOUT_SET=1; shift ;;
|
||||
--local) LOCAL_INSTALL=1; shift ;;
|
||||
--global) GLOBAL_INSTALL=1; shift ;;
|
||||
--prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;;
|
||||
@@ -333,6 +341,29 @@ if [ "$CLAUDE_MODEL_SET" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# --loadout is validated the same way: a name modules/loadouts.json defines.
|
||||
if [ "$LOADOUT_SET" -eq 1 ]; then
|
||||
if ! printf '%s' "$LOADOUT_ARG" | grep -Eq '^[a-z0-9-]{1,40}$' \
|
||||
|| ! _LOADOUT_CHECK="$(cd "$SOURCE_GSTACK_DIR" && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" bin/gstack-modules resolve --loadout "$LOADOUT_ARG" 2>&1)"; then
|
||||
printf 'Error: --loadout %s is not a loadout. %s\n' "$(printf '%s' "$LOADOUT_ARG" | tr -cd 'a-z0-9-' | cut -c1-40)" "$(printf '%s' "${_LOADOUT_CHECK:-}" | tail -1)" >&2
|
||||
echo " Nothing was installed or changed. List loadouts: bin/gstack-modules list" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# _persist_claude_loadout — save the validated --loadout (full clears it).
|
||||
_persist_claude_loadout() {
|
||||
[ "$LOADOUT_SET" -eq 1 ] || return 0
|
||||
if [ "$LOADOUT_ARG" = full ]; then
|
||||
"$SOURCE_GSTACK_DIR/bin/gstack-config" unset claude_loadout >/dev/null 2>&1 || true
|
||||
log "Claude loadout: full (claude_loadout cleared)."
|
||||
return 0
|
||||
fi
|
||||
if ! "$SOURCE_GSTACK_DIR/bin/gstack-config" set claude_loadout "$LOADOUT_ARG" >/dev/null; then
|
||||
echo "Error: could not save claude_loadout. Nothing was installed or changed." >&2
|
||||
exit 1
|
||||
fi
|
||||
log "Claude loadout: $LOADOUT_ARG (saved as claude_loadout; ./setup --loadout full returns to the full skills)."
|
||||
}
|
||||
# _persist_claude_model — save the validated --claude-model value as given.
|
||||
_persist_claude_model() {
|
||||
local overlay generic
|
||||
@@ -478,7 +509,16 @@ $_RF_TARGETS" in
|
||||
$HOST "*) ;;
|
||||
*) if [ "$HOST_FLAG_SET" -eq 1 ] || [ -z "$_RF_TARGETS" ]; then _rf_add "$HOST" global -; fi ;;
|
||||
esac
|
||||
# The children read claude_overlay_model, so the parent saves it first.
|
||||
# The children read claude_overlay_model and claude_loadout, so the parent saves them first.
|
||||
if [ "$LOADOUT_SET" -eq 1 ]; then
|
||||
case "
|
||||
$_RF_TARGETS" in
|
||||
*"
|
||||
claude "*) _persist_claude_loadout ;;
|
||||
*) echo "Error: --loadout applies to Claude installs, and no Claude install is registered for this checkout. Nothing was installed or changed. Install Claude first: ./setup --loadout $LOADOUT_ARG" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
if [ "$CLAUDE_MODEL_SET" -eq 1 ]; then
|
||||
case "
|
||||
$_RF_TARGETS" in
|
||||
@@ -1140,7 +1180,7 @@ _render_install() {
|
||||
rm -rf "$tmp"
|
||||
rc=0
|
||||
if [ "$host" = claude ]; then
|
||||
out="$(cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs:user --host claude --out-dir "$tmp" --link-root "$render" --install-root "$named" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} 2>&1)" || rc=$?
|
||||
out="$(cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs:user --host claude --out-dir "$tmp" --link-root "$render" --install-root "$named" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ${_LOADOUT_ARG:+"$_LOADOUT_ARG"} 2>&1)" || rc=$?
|
||||
want="$(find "$SOURCE_GSTACK_DIR" -mindepth 2 -maxdepth 2 -name SKILL.md | wc -l)"
|
||||
got="$(find "$tmp" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l)"
|
||||
[ "$rc" -ne 0 ] || { [ "$want" -eq "$got" ] && grep -qF "$named/bin/gstack-skill-start --skill" "$tmp/SKILL.md" 2>/dev/null; } \
|
||||
@@ -1452,6 +1492,10 @@ if [ "$MODEL_OVERRIDE_SET" -eq 1 ] && [ "$INSTALL_CODEX" -eq 0 ]; then
|
||||
echo "Error: --model is supported only when Codex is selected (--host codex or --host auto with Codex installed). For the Claude overlay, use --claude-model <id>." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$LOADOUT_SET" -eq 1 ] && [ "$INSTALL_CLAUDE" -eq 0 ]; then
|
||||
echo "Error: --loadout is supported only when Claude is selected (the default host, --host claude, or --host auto with Claude Code installed). Nothing was installed or changed." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CLAUDE_MODEL_SET" -eq 1 ] && [ "$INSTALL_CLAUDE" -eq 0 ]; then
|
||||
echo "Error: --claude-model is supported only when Claude is selected (the default host, --host claude, or --host auto with Claude Code installed). Nothing was installed or changed." >&2
|
||||
exit 1
|
||||
@@ -1467,6 +1511,10 @@ for _rr in "$INSTALL_KIRO kiro $KIRO_SKILLS/gstack" "$INSTALL_FACTORY factory $F
|
||||
_runtime_root_refused "${_rr%% *}" "${_rr#* }" && exit 1
|
||||
done
|
||||
_persist_claude_model
|
||||
_persist_claude_loadout
|
||||
# claude_loadout (GSTA-161): a non-full loadout renders the Claude skills into
|
||||
# the untracked render dir, like plan_mode_preamble=off (docs/loadouts.md).
|
||||
gstack_claude_loadout_arg "$GSTACK_CONFIG"
|
||||
|
||||
migrate_direct_codex_install() {
|
||||
local gstack_dir="$1"
|
||||
@@ -4410,7 +4458,7 @@ if [ -x "$DETECT_BIN" ]; then
|
||||
# No pipe before the || guard: `cmd | tail -3` reports TAIL's exit
|
||||
# status, so a generator crash read as success (same masking the
|
||||
# main gen:skill-docs site had). Capture, show the tail, propagate.
|
||||
_GEN_USER_OUT=$(bun_cmd run gen:skill-docs:user --host claude --out-dir "$_GSTACK_RENDER_TMP" --link-root "$_GSTACK_RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} 2>&1)
|
||||
_GEN_USER_OUT=$(bun_cmd run gen:skill-docs:user --host claude --out-dir "$_GSTACK_RENDER_TMP" --link-root "$_GSTACK_RENDER_DIR" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} ${_LOADOUT_ARG:+"$_LOADOUT_ARG"} 2>&1)
|
||||
_GEN_USER_RC=$?
|
||||
printf '%s\n' "$_GEN_USER_OUT" | tail -3
|
||||
exit "$_GEN_USER_RC"
|
||||
@@ -4437,7 +4485,7 @@ if [ -x "$DETECT_BIN" ]; then
|
||||
# A render from a previous gbrain install would shadow canonical files
|
||||
# on the next link/relink — drop it and restore canonical links. A
|
||||
# pinned overlay re-renders it without brain blocks instead (below).
|
||||
if [ -d "$_GSTACK_RENDER_DIR" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ] && [ "$INSTALL_CLAUDE" -eq 1 ] && [ "$_GSTACK_OVERLAY" = claude ] && [ -z "$_PLAN_MODE_ARG" ]; then
|
||||
if [ -d "$_GSTACK_RENDER_DIR" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ] && [ "$INSTALL_CLAUDE" -eq 1 ] && [ "$_GSTACK_OVERLAY" = claude ] && [ -z "$_PLAN_MODE_ARG" ] && [ -z "$_LOADOUT_ARG" ]; then
|
||||
rm -rf "$_GSTACK_RENDER_DIR"
|
||||
gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" absent removed
|
||||
if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then
|
||||
|
||||
Vendored
+1
-1
@@ -9,7 +9,7 @@
|
||||
"browser-skills/hackernews-frontpage": 371,
|
||||
"canary": 7738,
|
||||
"captain": 6551,
|
||||
"capy/skill/gstack": 5312,
|
||||
"capy/skill/gstack": 5363,
|
||||
"careful": 1239,
|
||||
"codex": 9890,
|
||||
"context-restore": 6287,
|
||||
|
||||
+2
@@ -4,6 +4,8 @@
|
||||
{ "file": "scripts/modules.ts", "movedFrom": "(new: module loadouts catalog and selection, GSTA-140)" },
|
||||
{ "file": "scripts/modules-render.ts", "movedFrom": "(new: module loadout render and lock, GSTA-140)" },
|
||||
{ "file": "scripts/modules-cli.ts", "movedFrom": "(new: bin/gstack-modules CLI, GSTA-140)" },
|
||||
{ "file": "scripts/modules-install.ts", "movedFrom": "(new: lite loadout install tree with identity-keyed reuse, GSTA-161)" },
|
||||
{ "file": "lib/model-detect.ts", "movedFrom": "(new: explicit model to loadout detection, GSTA-161)" },
|
||||
{ "file": "lib/harness-register.ts", "movedFrom": "(new: gstack-harness registrar, GSTA-135)" },
|
||||
{ "file": "lib/harness-qm.ts", "movedFrom": "(new: gstack-harness QM skill-pack target, GSTA-135)" },
|
||||
{ "file": "lib/harness-start-hook.ts", "movedFrom": "(new: gstack-harness start hook for OpenClaw and Hermes, GST-718)" },
|
||||
|
||||
@@ -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);
|
||||
@@ -550,6 +550,49 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
|
||||
expect(fs.readFileSync(launch, 'utf8')).not.toContain('GSTACK_PREFERENCES_FILE');
|
||||
});
|
||||
|
||||
test('--model: Opus 5.5 gets the lite SKILL_ROOT and its own launch file; another model, a failed render and no --model keep the verdict and its launch file', () => {
|
||||
const f = fixture();
|
||||
script(path.join(f.root, 'bin/gstack-modules'), [
|
||||
'echo "modules $*" >> "$CAPY_LOG"',
|
||||
'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 ;;',
|
||||
' *) echo "LOADOUT: full model=none source=model-flag reason=unmapped"; echo "SKILL_ROOT: $full" ;;',
|
||||
'esac',
|
||||
]);
|
||||
const launchOf = (out: string) => out.match(/^GSTACK_LAUNCH_FILE: (.+)$/m)![1];
|
||||
const opus = run(f, ['--for', 'ship', '--model', 'claude-opus-5-5']);
|
||||
expect(opus.status, opus.stdout + opus.stderr).toBe(0);
|
||||
expect(f.calls().filter(l => l.startsWith('modules '))).toEqual([`modules install --model claude-opus-5-5 --full-root ${f.home}/.claude/skills`]);
|
||||
expect(opus.stdout).toContain(`LOADOUT: lite model=claude-opus-5-5 source=model-flag digest=0123456789ab reused=no\nSKILL_ROOT: ${f.home}/lite-0123456789ab\n`);
|
||||
const [verdict] = verdicts(f);
|
||||
const base = verdict.replace(/\.json$/, '.env');
|
||||
const lite = launchOf(opus.stdout);
|
||||
expect(lite).toBe(verdict.replace(/\.json$/, '.loadout-lite-0123456789ab.env'));
|
||||
expect(fs.statSync(lite).mode & 0o777).toBe(0o600);
|
||||
expect(fs.readFileSync(lite, 'utf8')).toBe(fs.readFileSync(base, 'utf8') + `export GSTACK_LOADOUT_SKILL_ROOT='${f.home}/lite-0123456789ab'\n`);
|
||||
expect(fs.readFileSync(base, 'utf8')).not.toContain('GSTACK_LOADOUT_SKILL_ROOT');
|
||||
expect(lastLine(opus.stdout)).toMatch(/^gstack: ok 1\.2\.3 /);
|
||||
|
||||
const sonnet = run(f, ['--for', 'ship', '--model', 'claude-sonnet-5-5']);
|
||||
expect(sonnet.status).toBe(0);
|
||||
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_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);
|
||||
expect(launchOf(plain.stdout)).toBe(base);
|
||||
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);
|
||||
});
|
||||
|
||||
test('the verdict file records checkout, HEAD, workflow, trailer and the Bun resolution, keyed by checkout and project; --check certifies without setup or downloads', () => {
|
||||
const f = fixture();
|
||||
const r = run(f, ['--for', 'autoplan']);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* ./setup --loadout through the real ./setup on a default Claude install
|
||||
* (GSTA-161, PR 1b). A non-full loadout is saved as claude_loadout and served
|
||||
* from the untracked Claude render dir by setup's own gen:skill-docs render
|
||||
* (the plan-mode-preamble path), so the checkout stays full. An explicit
|
||||
* loadout is bound to no model, so its start lines carry no re-read rule. --loadout full serves the committed files again; an unknown name
|
||||
* changes nothing. Fixture: test/helpers/install-fixture.ts.
|
||||
*/
|
||||
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { config, defaultRender, failableBun, fakeDetect, record, served, setup } from './helpers/claude-overlay-fixture';
|
||||
import { ROOT, cleanupFixtures, cleanupSeed, makeFixture, makeSource } from './helpers/install-fixture';
|
||||
import { readSkillStartup } from './helpers/skill-startup';
|
||||
|
||||
afterEach(cleanupFixtures);
|
||||
afterAll(cleanupSeed);
|
||||
|
||||
const VOICE = '## Voice';
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('./setup --loadout', () => {
|
||||
test('lite renders the default install without the coaching pieces and keeps the checkout full; full serves the committed files; an unknown name changes nothing', () => {
|
||||
const f = makeFixture();
|
||||
failableBun(f);
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
fakeDetect(src);
|
||||
const skills = join(f.home, '.claude/skills');
|
||||
const committed = readSkillStartup(join(ROOT, 'autoplan/SKILL.md'));
|
||||
expect(committed).toContain(VOICE);
|
||||
|
||||
const lite = setup(f, src, ['--loadout', 'lite']);
|
||||
expect(lite.status, lite.out).toBe(0);
|
||||
expect(lite.out).toContain('and the lite loadout');
|
||||
expect(config(f, src, 'get', 'claude_loadout').stdout.trim()).toBe('lite');
|
||||
expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', render: 'user', loadout: 'lite' });
|
||||
const startup = served(skills);
|
||||
expect(startup).not.toContain(VOICE);
|
||||
expect(startup).toContain('--skill "autoplan" --model "claude"\n');
|
||||
expect(startup).not.toContain('--loadout');
|
||||
expect(readSkillStartup(join(src, 'autoplan/SKILL.md'))).toBe(committed);
|
||||
|
||||
const again = setup(f, src);
|
||||
expect(again.status, again.out).toBe(0);
|
||||
expect(served(skills)).not.toContain(VOICE);
|
||||
|
||||
const unknown = setup(f, src, ['--loadout', 'nope']);
|
||||
expect(unknown.status).toBe(1);
|
||||
expect(unknown.out).toContain('Nothing was installed or changed');
|
||||
expect(config(f, src, 'get', 'claude_loadout').stdout.trim()).toBe('lite');
|
||||
|
||||
const full = setup(f, src, ['--loadout', 'full']);
|
||||
expect(full.status, full.out).toBe(0);
|
||||
expect(config(f, src, 'get', 'claude_loadout').stdout.trim()).toBe('full');
|
||||
expect(existsSync(defaultRender(f))).toBe(false);
|
||||
expect(served(skills)).toBe(committed);
|
||||
expect(readFileSync(join(src, 'SKILL.md'), 'utf8')).toBe(readFileSync(join(ROOT, 'SKILL.md'), 'utf8'));
|
||||
}, 300_000);
|
||||
|
||||
test('gstack-config refuses a claude_loadout that is not a loadout name and stores full', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
const bad = config(f, src, 'set', 'claude_loadout', 'Bad Name');
|
||||
expect(bad.stderr).toContain('claude_loadout must be a loadout name');
|
||||
expect(config(f, src, 'get', 'claude_loadout').stdout.trim()).toBe('full');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Loadout detection (GSTA-161, PR 1b): lib/model-detect.ts resolves a loadout
|
||||
* from explicit inputs only, and gstack-skill-start prints a lite render's
|
||||
* LOADOUT lines without executing or echoing a hostile model id.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { resolveLoadout } from '../lib/model-detect';
|
||||
import { ModuleError, loadLoadouts } from '../scripts/modules';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const manifest = loadLoadouts();
|
||||
const resolve = (input: { loadout?: string; model?: string; env?: Record<string, string> }) => resolveLoadout({ env: {}, ...input, manifest });
|
||||
|
||||
describe('resolveLoadout', () => {
|
||||
test('only Claude Opus 5.5 (bare, provider-prefixed, dated snapshot or -latest) maps to lite, bound to the base id', () => {
|
||||
for (const model of ['claude-opus-5-5', 'anthropic/claude-opus-5-5', ' Claude-Opus-5-5[1m] ', 'claude-opus-5-5-20261101', 'claude-opus-5-5-latest']) {
|
||||
expect(resolve({ model })).toEqual({ loadout: 'lite', source: 'model-flag', model: 'claude-opus-5-5' });
|
||||
}
|
||||
});
|
||||
|
||||
test('every other model, including near misses of the Opus 5.5 id, resolves to full and never errors', () => {
|
||||
const near = ['claude-opus-5-6', 'claude-opus-5', 'claude-opus-55x', 'claude-opus-5-50', 'claude-opus-5-5x', 'claude-opus-5-5-1', 'claude-opus-5-5-2026110'];
|
||||
const others = ['claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-fable-5-1', 'gpt-6-astra', 'some-new-model', 'constructor', 'opus'];
|
||||
for (const model of [...near, ...others]) expect(resolve({ model })).toEqual({ loadout: 'full', source: 'model-flag', model: null, reason: 'unmapped' });
|
||||
});
|
||||
|
||||
test('an id that is not a plain model id resolves to full with a sanitized notice', () => {
|
||||
for (const model of ['a$(touch /tmp/x)', "o'pus", 'claude\nopus', 'x'.repeat(200), 'us.anthropic.claude-opus-5-5:0', 'claude-opus-5-5@2026']) {
|
||||
const r = resolve({ model });
|
||||
expect(r).toMatchObject({ loadout: 'full', model: null, reason: 'invalid' });
|
||||
expect(r.notice).toMatch(/^LOADOUT_INVALID_MODEL: ".{0,40}" is not a plain model id; using full/);
|
||||
expect(r.notice).not.toContain('\n');
|
||||
}
|
||||
});
|
||||
|
||||
test('order: an explicit loadout (bound to no model), then --model, then GSTACK_MODEL, then full', () => {
|
||||
expect(resolve({ loadout: 'lite', model: 'claude-sonnet-5-5' })).toEqual({ loadout: 'lite', source: 'loadout-flag', model: null });
|
||||
expect(resolve({ loadout: 'full', model: 'claude-opus-5-5' })).toEqual({ loadout: 'full', source: 'loadout-flag', model: null });
|
||||
expect(resolve({ model: 'claude-sonnet-5-5', env: { GSTACK_MODEL: 'claude-opus-5-5' } })).toMatchObject({ loadout: 'full', source: 'model-flag' });
|
||||
expect(resolve({ env: { GSTACK_MODEL: 'claude-opus-5-5' } })).toEqual({ loadout: 'lite', source: 'GSTACK_MODEL', model: 'claude-opus-5-5' });
|
||||
expect(resolve({ env: { GSTACK_LOADOUT: 'lite', GSTACK_MODEL: ' ' } })).toEqual({ loadout: 'full', source: 'default', model: null });
|
||||
expect(resolve({})).toEqual({ loadout: 'full', source: 'default', model: null });
|
||||
});
|
||||
|
||||
test('an unknown explicit loadout is refused; a manifest without a models map serves full', () => {
|
||||
expect(() => resolve({ loadout: 'nope' })).toThrow(ModuleError);
|
||||
expect(resolveLoadout({ model: 'claude-opus-5-5', env: {}, manifest: { ...manifest, models: undefined } })).toMatchObject({ loadout: 'full', reason: 'unmapped' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-skill-start LOADOUT lines', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'loadout-start-'));
|
||||
afterAll(() => fs.rmSync(home, { recursive: true, force: true }));
|
||||
const start = (args: string[], env: Record<string, string> = {}) => {
|
||||
const r = spawnSync('bash', [path.join(ROOT, 'bin/gstack-skill-start'), '--skill', 'qa', '--model', 'claude', ...args], {
|
||||
cwd: home, encoding: 'utf-8', timeout: 60_000, env: { PATH: process.env.PATH ?? '', HOME: home, GSTACK_HOME: path.join(home, '.gstack'), ...env },
|
||||
});
|
||||
return r.stdout.split('\n').filter(l => l.startsWith('LOADOUT'));
|
||||
};
|
||||
const bound = ['--loadout', 'lite', '--loadout-model', 'claude-opus-5-5', '--full-root', '/opt/skills'];
|
||||
const rule = 'LOADOUT_RULE: this file was rendered for claude-opus-5-5. If you are not running claude-opus-5-5, read /opt/skills/qa/SKILL.md now and follow it instead of this file.';
|
||||
|
||||
test('a model-bound render names its model and full root and says when to read the full file', () => {
|
||||
expect(start(bound)).toEqual(['LOADOUT: lite model=claude-opus-5-5 full_root=/opt/skills', rule]);
|
||||
for (const running of ['claude-opus-5-5', 'Anthropic/Claude-Opus-5-5 [1m]', 'claude-opus-5-5-20261101']) expect(start(bound, { GSTACK_MODEL: running })).toEqual(['LOADOUT: lite model=claude-opus-5-5 full_root=/opt/skills', rule]);
|
||||
});
|
||||
|
||||
test('a different running model gets LOADOUT_MISMATCH; a hostile GSTACK_MODEL is neither run nor echoed', () => {
|
||||
expect(start(bound, { GSTACK_MODEL: 'claude-sonnet-5-5' }).at(-1)).toBe('LOADOUT_MISMATCH: claude-opus-5-5 vs claude-sonnet-5-5: read /opt/skills/qa/SKILL.md now and follow it instead of this file.');
|
||||
expect(start(bound, { GSTACK_MODEL: 'claude-opus-5-50' }).at(-1)).toContain('LOADOUT_MISMATCH: claude-opus-5-5 vs claude-opus-5-50');
|
||||
const marker = path.join(home, 'pwned');
|
||||
const lines = start(bound, { GSTACK_MODEL: `$(touch ${marker})` });
|
||||
expect(lines.at(-1)).toBe('LOADOUT_MISMATCH: claude-opus-5-5 vs an invalid GSTACK_MODEL: read /opt/skills/qa/SKILL.md now and follow it instead of this file.');
|
||||
expect(fs.existsSync(marker)).toBe(false);
|
||||
});
|
||||
|
||||
test('a malformed bound start line points at the full file; a full or explicit render prints nothing', () => {
|
||||
const malformed = 'LOADOUT: lite model=unknown: the start line is malformed; read the full SKILL.md for this skill instead of this file.';
|
||||
expect(start(['--loadout', 'lite'])).toEqual([malformed]);
|
||||
expect(start(['--loadout', 'lite', '--loadout-model', 'x;id', '--full-root', '/opt/skills'])).toEqual([malformed]);
|
||||
expect(start(['--loadout', 'lite', '--loadout-model', 'claude-opus-5-5', '--full-root', 'relative'])).toEqual([malformed]);
|
||||
expect(start([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* gstack-modules install (GSTA-161, PR 1b): an Opus 5.5 session gets a
|
||||
* complete, immutable lite tree beside the full install; every other model
|
||||
* gets the full root. Covers the round-2 review findings: the folder identity
|
||||
* covers model, full root, selection and source tree; runtime assets resolve
|
||||
* from the printed SKILL_ROOT; concurrent publishers of one identity agree;
|
||||
* the committed checkout stays byte-identical.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import { selectModules } from '../scripts/modules';
|
||||
import { installLoadout, sourceTree } from '../scripts/modules-install';
|
||||
import { LOCK_FILE } from '../scripts/modules-render';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loadout-install-'));
|
||||
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const FULL_ROOT = '/opt/claude/skills';
|
||||
const lite = selectModules({ loadout: 'lite' })!;
|
||||
const run = (...args: string[]) => {
|
||||
const r = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'bin/gstack-modules'), ...args], { cwd: ROOT, encoding: 'utf-8', timeout: 120_000 });
|
||||
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
|
||||
};
|
||||
const digest = (file: string) => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
const committed = ['qa/SKILL.md', 'land-and-deploy/SKILL.md', 'SKILL.md'];
|
||||
|
||||
describe('gstack-modules install', () => {
|
||||
test('two models on one machine: Opus 5.5 gets the published lite tree, Sonnet the full root; the checkout is untouched', () => {
|
||||
const store = path.join(tmp, 'two-models');
|
||||
const before = committed.map(rel => digest(path.join(ROOT, rel)));
|
||||
const opus = run('install', '--model', 'anthropic/claude-opus-5-5', '--full-root', FULL_ROOT, '--store', store);
|
||||
expect(opus.code).toBe(0);
|
||||
const [line, rootLine] = opus.stdout.trim().split('\n');
|
||||
expect(line).toMatch(/^LOADOUT: lite model=claude-opus-5-5 source=model-flag digest=[0-9a-f]{12} reused=no$/);
|
||||
const root = rootLine.replace('SKILL_ROOT: ', '');
|
||||
expect(path.dirname(root)).toBe(store);
|
||||
const sonnet = run('install', '--model', 'claude-sonnet-5-5', '--full-root', FULL_ROOT, '--store', store);
|
||||
expect(sonnet).toMatchObject({ code: 0, stdout: `LOADOUT: full model=none source=model-flag reason=unmapped\nSKILL_ROOT: ${FULL_ROOT}\n` });
|
||||
expect(run('install', '--model', 'claude-opus-5-5', '--full-root', FULL_ROOT, '--store', store).stdout).toContain(`reused=yes\nSKILL_ROOT: ${root}\n`);
|
||||
expect(fs.readdirSync(store)).toEqual([path.basename(root)]);
|
||||
expect(committed.map(rel => digest(path.join(ROOT, rel)))).toEqual(before);
|
||||
expect(fs.readFileSync(path.join(ROOT, 'qa/SKILL.md'), 'utf-8')).not.toContain('--loadout');
|
||||
}, 240_000);
|
||||
|
||||
test('the lite tree is a complete skill tree: lite sections, baked start lines, runtime assets and the router', async () => {
|
||||
const { root, lock } = await installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: FULL_ROOT, store: path.join(tmp, 'tree') });
|
||||
const qa = fs.readFileSync(path.join(root, 'qa/SKILL.md'), 'utf-8');
|
||||
expect(qa).toContain(`gstack-skill-start --skill "qa" --model "claude" --loadout 'lite' --loadout-model 'claude-opus-5-5' --full-root '${FULL_ROOT}'`);
|
||||
expect(qa).toContain(`${root}/qa/sections/`);
|
||||
for (const rel of ['qa/templates/qa-report-template.md', 'qa/templates/functional-report-template.md', 'qa/references/issue-taxonomy.md', 'qa/sections/manifest.json', 'gstack/SKILL.md']) {
|
||||
expect(fs.existsSync(path.join(root, rel))).toBe(true);
|
||||
}
|
||||
expect(fs.readFileSync(path.join(root, 'qa/templates/qa-report-template.md'))).toEqual(fs.readFileSync(path.join(ROOT, 'qa/templates/qa-report-template.md')));
|
||||
expect(fs.existsSync(path.join(root, 'qa/SKILL.md.tmpl'))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(root, 'gstack/SKILL.md'))).toEqual(fs.readFileSync(path.join(root, 'SKILL.md')));
|
||||
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');
|
||||
expect(lock.excluded).not.toContain('preamble:brain-sync');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(root, LOCK_FILE), 'utf-8')).identity.source_tree).toBe(sourceTree(ROOT));
|
||||
}, 240_000);
|
||||
|
||||
test('the identity covers the bound model and full root; an explicit lite is unbound', async () => {
|
||||
const store = path.join(tmp, 'identity');
|
||||
const a = await installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: FULL_ROOT, store });
|
||||
const b = await installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: '/other/skills', store });
|
||||
const c = await installLoadout({ selection: lite, boundModel: null, fullRoot: FULL_ROOT, store });
|
||||
expect(new Set([a.root, b.root, c.root]).size).toBe(3);
|
||||
const explicit = fs.readFileSync(path.join(c.root, 'qa/SKILL.md'), 'utf-8');
|
||||
expect(explicit).toContain('--skill "qa" --model "claude"\n');
|
||||
expect(explicit).not.toContain('--loadout');
|
||||
}, 240_000);
|
||||
|
||||
test('concurrent publishers of one identity agree on one folder; a tampered folder is refused, not reused', async () => {
|
||||
const store = path.join(tmp, 'race');
|
||||
const [x, y] = await Promise.all([1, 2].map(() => installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: FULL_ROOT, store })));
|
||||
expect(x.root).toBe(y.root);
|
||||
expect(fs.readdirSync(store)).toEqual([path.basename(x.root)]);
|
||||
fs.appendFileSync(path.join(x.root, 'qa/SKILL.md'), 'tampered\n');
|
||||
await expect(installLoadout({ selection: lite, boundModel: 'claude-opus-5-5', fullRoot: FULL_ROOT, store })).rejects.toThrow(/RENDER_DEST_NOT_OWNED/);
|
||||
}, 240_000);
|
||||
|
||||
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');
|
||||
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([inUse.root, old.root, newest.root].every(dir => fs.existsSync(dir))).toBe(true);
|
||||
expect(fs.existsSync(oldest.root)).toBe(false);
|
||||
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', () => {
|
||||
const repo = path.join(tmp, 'repo');
|
||||
fs.mkdirSync(repo);
|
||||
const git = (...args: string[]) => spawnSync('git', args, { cwd: repo, encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' } });
|
||||
git('init', '-q');
|
||||
fs.writeFileSync(path.join(repo, 'a.md'), 'one\n');
|
||||
git('add', '-A');
|
||||
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'one');
|
||||
const clean = sourceTree(repo);
|
||||
expect(clean).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(sourceTree(repo)).toBe(clean);
|
||||
fs.writeFileSync(path.join(repo, 'a.md'), 'two\n');
|
||||
const dirty = sourceTree(repo);
|
||||
expect(dirty.startsWith(`${clean}+`)).toBe(true);
|
||||
fs.writeFileSync(path.join(repo, 'b.md'), 'new\n');
|
||||
expect(sourceTree(repo)).not.toBe(dirty);
|
||||
git('add', '-A');
|
||||
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'two');
|
||||
expect(sourceTree(repo)).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(sourceTree(repo)).not.toBe(clean);
|
||||
expect(sourceTree(path.join(tmp, 'not-a-repo-at-all'))).toMatch(/^unpinned-/);
|
||||
});
|
||||
|
||||
test('a lite render that fails serves full with LOADOUT_FALLBACK, exit 0', () => {
|
||||
const store = path.join(tmp, 'blocked');
|
||||
fs.writeFileSync(store, 'a file, not a directory\n');
|
||||
const r = run('install', '--model', 'claude-opus-5-5', '--full-root', FULL_ROOT, '--store', store);
|
||||
expect(r).toMatchObject({ code: 0, stdout: `LOADOUT: full model=none source=model-flag reason=render-failed\nSKILL_ROOT: ${FULL_ROOT}\n` });
|
||||
expect(r.stderr).toContain('LOADOUT_FALLBACK: ');
|
||||
expect(run('install', '--model', 'claude-opus-5-5', '--full-root', 'relative/path')).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') });
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Module loadouts (GSTA-140, PR 1a): modules/loadouts.json, the derived
|
||||
* catalog, --modules selection, the render filter, and gstack-modules render.
|
||||
* Every rung loads `full` today, so the synthetic ad-hoc arms below are the
|
||||
* only exclusion coverage; committed bytes are covered by Skill Docs Freshness.
|
||||
* Module loadouts (GSTA-140, PR 1a; GSTA-161, PR 1b): modules/loadouts.json,
|
||||
* the derived catalog, --modules selection, the render filter, and
|
||||
* gstack-modules render. Every rung loads `full`; the one named exclusion set
|
||||
* is `lite`, mapped only from Claude Opus 5.5 (install and detection:
|
||||
* test/module-loadouts-install.test.ts, test/model-detect.test.ts). Committed
|
||||
* bytes are covered by Skill Docs Freshness.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
@@ -46,6 +48,24 @@ describe('modules/loadouts.json', () => {
|
||||
expect(Object.values(manifest.default)).toEqual(['full', 'full', 'full', 'full', 'full']);
|
||||
});
|
||||
|
||||
test('lite drops the eleven coaching pieces, keeps upgrade-check and brain-sync, and only Opus 5.5 maps to it', () => {
|
||||
const coaching = ['voice', 'writing-style', 'completeness', 'confusion', 'evidence', 'context-health', 'question-tuning', 'search-before-building', 'repo-mode', 'brain-health', 'context-recovery'];
|
||||
expect(manifest.loadouts.lite).toMatchObject({ base: 'full', exclude: coaching.map(id => `preamble:${id}`) });
|
||||
expect(manifest.models).toEqual({ 'claude-opus-5-5': 'lite' });
|
||||
const s = selectModules({ loadout: 'lite' })!;
|
||||
expect([...s.excluded].sort()).toEqual(coaching.map(id => `preamble:${id}`).sort());
|
||||
for (const kept of ['preamble:upgrade-check', 'preamble:brain-sync', ...manifest.pinned]) expect(s.excluded.has(kept)).toBe(false);
|
||||
});
|
||||
|
||||
test('a broken models map names each problem', () => {
|
||||
const bad = structuredClone(manifest);
|
||||
bad.models = { 'Claude-Opus*': 'lite', 'claude-opus-5-5': 'ghost', 'claude-opus-5-5-latest': 'lite' };
|
||||
const problems = validateLoadouts(bad, catalog, CORPUS).join('\n');
|
||||
expect(problems).toContain('models key "Claude-Opus*" must be an exact model id');
|
||||
expect(problems).toContain('models.claude-opus-5-5 names no loadout');
|
||||
expect(problems).toContain('models key claude-opus-5-5-latest is also matched by claude-opus-5-5');
|
||||
});
|
||||
|
||||
test('the catalog derives every module kind from its source files', () => {
|
||||
const ids = new Set(catalog.map(e => e.id));
|
||||
expect(ids.has('skill:gstack')).toBe(true);
|
||||
@@ -251,6 +271,11 @@ describe('modules/loadouts.schema.json', () => {
|
||||
expect([...schema.properties.aliases.additionalProperties.enum].sort()).toEqual([...rungs].sort());
|
||||
const pattern = new RegExp(schema.properties.rungs.properties.frontier.items.pattern);
|
||||
for (const rung of rungs) for (const glob of raw.rungs[rung]) expect(glob).toMatch(pattern);
|
||||
const modelKey = new RegExp(schema.properties.models.propertyNames.pattern);
|
||||
for (const [key, loadout] of Object.entries(raw.models as Record<string, string>)) {
|
||||
expect(key).toMatch(modelKey);
|
||||
expect(Object.keys(raw.loadouts)).toContain(loadout);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,6 +316,19 @@ describe('bin/gstack-modules', () => {
|
||||
expect(spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout).toBe(before);
|
||||
});
|
||||
|
||||
test('render --bind-model bakes the start lines an install for that model serves, and only for an exclusion loadout', () => {
|
||||
const out = path.join(tmp, 'cli-bind');
|
||||
const result = run('render', '--host', 'claude', '--loadout', 'lite', '--bind-model', 'anthropic/claude-opus-5-5', '--full-root', '/opt/full', '--out-dir', out);
|
||||
expect(result.code).toBe(0);
|
||||
expect(fs.readFileSync(path.join(out, 'qa/SKILL.md'), 'utf-8')).toContain(`--skill "qa" --model "claude" --loadout 'lite' --loadout-model 'claude-opus-5-5' --full-root '/opt/full'`);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(out, LOCK_FILE), 'utf-8'))).toMatchObject({ loadout: 'lite', loadout_model: 'claude-opus-5-5', full_root: '/opt/full' });
|
||||
const refused = path.join(tmp, 'cli-bind-refused');
|
||||
for (const args of [['--loadout', 'full', '--bind-model', 'claude-opus-5-5', '--full-root', '/opt/full'], ['--loadout', 'lite', '--bind-model', 'claude-opus-5-5'], ['--loadout', 'lite', '--bind-model', 'a$(id)', '--full-root', '/opt/full'], ['--loadout', 'lite', '--bind-model', 'claude-opus-5-5', '--full-root', 'rel']]) {
|
||||
expect(run('render', '--host', 'claude', ...args, '--out-dir', refused)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') });
|
||||
}
|
||||
expect(fs.existsSync(refused)).toBe(false);
|
||||
});
|
||||
|
||||
test('render prints one RENDERED line and writes the lock', () => {
|
||||
const out = path.join(tmp, 'cli-render');
|
||||
const result = run('render', '--host', 'claude', '--modules', 'full,-skill:design-shotgun', '--out-dir', out);
|
||||
|
||||
Reference in new issue
Block a user