Merge remote-tracking branch 'origin/garrytan/opus55fu-wip-H2' into garrytan/opus55-followups

This commit is contained in:
garrytan committed 2026-10-03 21:36:47 +00:00
commit 86c07461a3
25 files changed
+1262 -35

No files matched your search

+1
View File
@@ -31,6 +31,7 @@ bun run eval:summary # aggregate stats across all eval runs
bun run eval:flake-rank # rank tests by flake signal (retried passes first; --json, --dir, --since-days)
bun run slop # full slop-scan report (all files)
bun run slop:diff # slop findings in files changed on this branch only
bun run audit:manifest # file slices for /claude-api prompt-audit; rerun it at each frontier-model release (CONTRIBUTING.md)
```
`test:evals` requires `ANTHROPIC_API_KEY`. Codex E2E tests (`test/codex-e2e.test.ts`,
+25
View File
@@ -603,6 +603,31 @@ is a CLI flag) so every caller gets it on both paths. Exported test seams:
`serveDir(root, nonce)`, `SAFE_TMP_DIR`, and `PAGE_NUMBER_FOOTER` (the one
page-number footer make-pdf, `gstack-render`, and the browse `pdf` command share).
## Prompt audit at each frontier-model release
When a new frontier Claude model ships, audit the text models read for
instructions the new model over-applies or no longer needs. The audit is
Anthropic's `/claude-api prompt-audit`, a Claude Code skill you run in your own
Claude Code session; gstack only prints what to feed it.
```bash
bun run audit:manifest # slices of templates, resolvers, overlays, CLAUDE.md and wording-pinning tests (--json for a machine-readable list)
```
1. In Claude Code, run `/claude-api prompt-audit` with the new model as the
target, one slice at a time, giving it that slice's file list. `s01` is the
shared text every skill reads (CLAUDE.md, model overlays, preamble
resolvers); a skill's template and its sections share one slice.
2. Fix findings in templates and resolvers, never in generated SKILL.md files,
then run `bun run gen:skill-docs --host all`.
3. Treat safety rules as held: reword one only where an eval shows the model
obeys it both before and after the change.
4. Work through the "tests that pin skill wording" slices last. Where prose
changed, replace exact-sentence pins with structural or meaning checks
(the prompt-bytes rule in CLAUDE.md's testing section).
5. `test/archaeology-lint.test.ts` keeps issue numbers and incident stories out
of the generated text; its failure names the source file to fix.
## Jargon list (V1 writing style)
gstack's Writing Style section (injected into every tier-≥2 skill's preamble)
+47 -4
View File
@@ -135,6 +135,14 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# # The vendor capture consent (`memorable enable`)
# # is separate; gstack never sets it.
#
# ─── Claude skill overlay ────────────────────────────────────────────
# claude_overlay_model: claude # Claude model ID whose skill overlay ./setup
# # renders (e.g. claude-opus-4-8). Absent = the
# # generic claude overlay. Set it with
# # ./setup --claude-model <id>, which validates the
# # ID and re-renders; ./setup --claude-model claude
# # returns to the generic overlay.
#
# ─── Advanced ────────────────────────────────────────────────────────
# codex_reviews: enabled # Workflow outside review (Codex or Claude Code by harness).
# # enabled: /review, /ship, /document-release, plan reviews,
@@ -195,6 +203,7 @@ lookup_default() {
pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks
memorable_recall) echo "off" ;; # on | off — Memorable bridge gate, fail-closed until `gstack-memorable enable`
founder_resources) echo "true" ;; # office-hours resource pitch — #538 permanent opt-out sets false
claude_overlay_model) echo "claude" ;; # set only by ./setup --claude-model; presence via `has`
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
# brain_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
# writes 'personal' for local engines,
@@ -660,6 +669,7 @@ case "${1:-}" in
# Run this after installing or uninstalling gbrain so your locally
# generated SKILL.md files match your installation state.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$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; }
DETECT_BIN="$SCRIPT_DIR/gstack-gbrain-detect"
DETECTION_FILE="$STATE_DIR/gbrain-detection.json"
mkdir -p "$STATE_DIR"
@@ -667,10 +677,10 @@ case "${1:-}" in
echo "gstack-gbrain-detect not found at $DETECT_BIN" >&2
exit 1
fi
if ! "$DETECT_BIN" > "$DETECTION_FILE.tmp" 2>/dev/null; then
printf '{"gbrain_on_path":false,"gbrain_local_status":"no-cli"}\n' > "$DETECTION_FILE.tmp"
if ! "$DETECT_BIN" > "$DETECTION_FILE.tmp.$$" 2>/dev/null; then
printf '{"gbrain_on_path":false,"gbrain_local_status":"no-cli"}\n' > "$DETECTION_FILE.tmp.$$"
fi
mv "$DETECTION_FILE.tmp" "$DETECTION_FILE"
mv "$DETECTION_FILE.tmp.$$" "$DETECTION_FILE"
# Summarize for the user. Use python (already required elsewhere) to
# parse the JSON portably; fall back to grep if python is unavailable.
@@ -721,10 +731,14 @@ case "${1:-}" in
# SKILL.md link dangling — the whole skill set vanished from
# Claude Code until a successful re-render. A failed render now
# leaves the previous render fully intact.
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" >/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" >/dev/null 2>&1 ); then
gstack_render_lock "$RENDER_DIR"
_swap_in_render "$RENDER_DIR" "$RENDER_TMP"
gstack_render_unlock "$RENDER_DIR"
gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" ok rendered
# Repoint installed skills at the render — gstack-relink prefers
# the render dir when present.
"$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true
@@ -734,12 +748,41 @@ case "${1:-}" in
rm -rf "$RENDER_TMP"
echo "Warning: render failed — previous render (if any) left in place, links stay valid."
echo "Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude --out-dir $RENDER_DIR' manually to see the error."
gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" ok failed
"$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true
fi
fi
;;
*)
echo "gbrain not detected (local-status: $STATUS) → brain-aware blocks will be suppressed in planning-skill SKILL.md files."
echo "Install gbrain (see /setup-gbrain) and re-run 'gstack-config gbrain-refresh' once it's configured."
# Drop a brain-aware render, or re-render a pinned overlay without
# 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; } \
&& [ -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'."
else
gstack_claude_overlay "$INSTALL_DIR" "$0"
gstack_claude_render_action "$RENDER_DIR" absent
if [ "$_GSTACK_RENDER_ACTION" = plain ]; then
if gstack_claude_render_plain "$INSTALL_DIR" "$RENDER_DIR"; then
gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent rendered
else
gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent failed
fi
else
if [ -d "$RENDER_DIR" ]; then
rm -rf "$RENDER_DIR"
echo "Removed the brain-aware render at $RENDER_DIR; Claude skills serve the committed files."
fi
gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent removed
fi
"$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true
fi
fi
;;
esac
;;
+200
View File
@@ -0,0 +1,200 @@
# gstack-render-claude.sh — the Claude skill render shared by ./setup and
# `gstack-config gbrain-refresh`. Source it; it only defines functions.
# bash-3.2-clean and safe under `set -euo pipefail`.
#
# Overlay selection is opt-in: the overlay is the family of the persisted
# `claude_overlay_model` (written only by `./setup --claude-model <id>`), or
# `claude` when that key is absent. The ID is re-resolved through
# scripts/models.ts at every render, so an ID that stops resolving after an
# upgrade falls back to `claude` with the reason printed.
#
# Each render keeps a plain-text activation record beside it
# (<render dir>.overlay: overlay, source, model, version, gbrain, render), so
# `./setup --status` and the change-only banner never need bun. No record
# means the default `claude` overlay on the committed render.
#
# 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.
_gstack_render_say() {
if declare -F log >/dev/null 2>&1; then log "$@"; else echo "$@"; fi
}
_gstack_render_bun() {
if declare -F bun_cmd >/dev/null 2>&1; then bun_cmd "$@"; else bun "$@"; fi
}
# gstack_claude_overlay SOURCE_DIR CONFIG_BIN — resolve the requested overlay.
# Sets _GSTACK_OVERLAY (family), _GSTACK_OVERLAY_SOURCE (explicit|default),
# _GSTACK_OVERLAY_ID (the configured ID, or claude), _GSTACK_OVERLAY_GENERIC
# (1 when a specific ID uses the generic overlay) and _GSTACK_OVERLAY_NOTE
# (why a configured ID fell back to claude, else empty; printed here).
gstack_claude_overlay() {
local src="$1" CONFIG_BIN="$2" out rc=0
_GSTACK_OVERLAY=claude
_GSTACK_OVERLAY_SOURCE=default
_GSTACK_OVERLAY_ID=claude
_GSTACK_OVERLAY_GENERIC=0
_GSTACK_OVERLAY_NOTE=""
"$CONFIG_BIN" has claude_overlay_model >/dev/null 2>&1 || return 0
_GSTACK_OVERLAY_SOURCE=explicit
_GSTACK_OVERLAY_ID="$("$CONFIG_BIN" get claude_overlay_model 2>/dev/null || true)"
out="$(cd "$src" && _gstack_render_bun run scripts/models.ts claude-overlay "$_GSTACK_OVERLAY_ID" 2>&1)" || rc=$?
if [ "$rc" -eq 0 ]; then
_GSTACK_OVERLAY="$(printf '%s' "$out" | tail -1 | cut -f1)"
_GSTACK_OVERLAY_GENERIC="$(printf '%s' "$out" | tail -1 | cut -f2)"
return 0
fi
_GSTACK_OVERLAY=claude
_GSTACK_OVERLAY_NOTE="claude_overlay_model '$_GSTACK_OVERLAY_ID' no longer resolves to a Claude overlay ($(printf '%s\n' "$out" | sed -n 2p)); using the generic claude overlay. Fix: ./setup --claude-model <claude-model-id>, or ./setup --claude-model claude."
_gstack_render_say " note: $_GSTACK_OVERLAY_NOTE"
}
# gstack_claude_overlay_field RECORD KEY — one field of an activation record.
gstack_claude_overlay_field() {
[ -f "$1" ] || return 0
sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1
}
# gstack_render_lock DIR / gstack_render_unlock DIR — serialize the swap and
# record write of concurrent renders (mkdir lock, no flock). A lock whose
# holder is gone is broken; after 30 s the swap proceeds unlocked.
gstack_render_lock() {
local lock="$1.lock" i=0 pid
mkdir -p "$(dirname "$1")" 2>/dev/null || true
while ! mkdir "$lock" 2>/dev/null; do
pid="$(cat "$lock/pid" 2>/dev/null || true)"
if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then
rm -rf "$lock"
continue
fi
i=$((i + 1))
[ "$i" -lt 300 ] || return 0
sleep 0.1
done
echo "$$" > "$lock/pid"
}
gstack_render_unlock() {
rm -rf "$1.lock"
}
# gstack_claude_render_action RENDER_DIR GBRAIN — what the default install's
# render dir needs: `user` (brain-aware :user render), `plain` (pinned
# overlay, no brain blocks), `remove` (serve the committed files) or `keep`.
# GBRAIN is ok, absent or unknown (detector missing or failed: no brain
# blocks, and a render is left alone unless an overlay change needs it).
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 ]; then _GSTACK_RENDER_ACTION=plain; return 0; fi
previous="$(gstack_claude_overlay_field "$render_dir.overlay" overlay)"
if [ "$gbrain" = unknown ] && [ "${previous:-claude}" = claude ]; then
_GSTACK_RENDER_ACTION=keep
else
_GSTACK_RENDER_ACTION=remove
fi
}
# gstack_claude_render_plain SOURCE_DIR RENDER_DIR — render the pinned overlay
# without brain blocks into a temp dir and swap it in only on success.
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 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" 2>&1)" || rc=$?
if [ "$rc" -ne 0 ]; then
rm -rf "$RENDER_TMP"
printf '%s\n' "$out" | tail -3
return "$rc"
fi
gstack_render_lock "$RENDER_DIR"
_swap_in_render "$RENDER_DIR" "$RENDER_TMP"
gstack_render_unlock "$RENDER_DIR"
}
# gstack_claude_render_settle SOURCE_DIR RENDER_DIR GBRAIN OUTCOME [DEFAULT]
# Record what a render step left in place and report it. OUTCOME is rendered,
# removed or failed. DEFAULT is 1 (default install: a failed pinned render
# from an older gstack version falls back to the committed files) or 0 (a
# per-install render keeps its previous render). Prints the overlay banner
# only when the served overlay differs from the previous record's.
gstack_claude_render_settle() {
local src="$1" render_dir="$2" gbrain="$3" outcome="$4" default="${5:-1}"
local record="$2.overlay" version previous previous_version served render_kind
version="$(cat "$src/VERSION" 2>/dev/null | tr -d '[:space:]' || true)"
previous="$(gstack_claude_overlay_field "$record" overlay)"
previous_version="$(gstack_claude_overlay_field "$record" version)"
case "$outcome" in
rendered) served="$_GSTACK_OVERLAY"; render_kind=user ;;
removed) served=claude; render_kind=committed ;;
failed)
if [ "$default" = 1 ] && [ "$_GSTACK_OVERLAY" != claude ] && [ -d "$render_dir" ] \
&& [ "$previous_version" != "$version" ]; then
rm -rf "$render_dir"
_gstack_render_say " The $_GSTACK_OVERLAY overlay render failed and the previous render came from gstack ${previous_version:-unknown}, so the committed claude render is served. Rerun ./setup to retry."
served=claude; render_kind=committed
elif [ -d "$render_dir" ]; then
[ "$_GSTACK_OVERLAY" = claude ] \
|| _gstack_render_say " The $_GSTACK_OVERLAY overlay render failed; the previous render (${previous:-claude} overlay) stays in use. Rerun ./setup to retry."
return 0
else
_gstack_render_say " The $_GSTACK_OVERLAY overlay render failed; the committed claude render is served. Rerun ./setup to retry."
served=claude; render_kind=committed
fi
;;
*) return 0 ;;
esac
gstack_render_lock "$render_dir"
if [ "$render_kind" = committed ] && [ "$_GSTACK_OVERLAY_SOURCE" = default ]; then
rm -f "$record"
else
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" \
> "$record.tmp.$$" && mv "$record.tmp.$$" "$record"
fi
gstack_render_unlock "$render_dir"
if [ "$served" != "${previous:-claude}" ]; then
_gstack_render_say ""
_gstack_render_say "Claude skill overlay: $served (was ${previous:-claude})."
if [ "$_GSTACK_OVERLAY_SOURCE" = explicit ]; then
_gstack_render_say " Source: claude_overlay_model = $_GSTACK_OVERLAY_ID, set by ./setup --claude-model."
[ "$_GSTACK_OVERLAY_GENERIC" = 1 ] && _gstack_render_say " $_GSTACK_OVERLAY_ID has no overlay of its own, so it uses the generic claude overlay."
else
_gstack_render_say " Source: default (no claude_overlay_model set)."
fi
_gstack_render_say " Return to the generic overlay: ./setup --claude-model claude"
fi
}
# gstack_claude_overlay_status — `./setup --status` overlay lines. Reads only
# activation records and the registry: no bun, no writes.
gstack_claude_overlay_status() {
local default_render rows dest render record overlay source model version kind
default_render="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}"
rows="$(gstack_install_registry_rows 2>/dev/null | awk -F '\t' '$1 == "claude" { print $4 "\t" $9 }')"
[ -n "$rows" ] || return 0
echo "Claude skill overlay:"
while IFS=' ' read -r dest render; do
[ -n "$dest" ] || continue
case "$render" in
''|-|committed) record="$default_render.overlay"; render="$default_render"; kind="user render" ;;
*) record="$render.overlay"; kind="per-install render" ;;
esac
overlay="$(gstack_claude_overlay_field "$record" overlay)"
[ -d "$render" ] || kind="committed render"
if [ -z "$overlay" ]; then
echo " $(_gstack_tilde "$dest"): claude (default, $kind)"
continue
fi
source="$(gstack_claude_overlay_field "$record" source)"
model="$(gstack_claude_overlay_field "$record" model)"
version="$(gstack_claude_overlay_field "$record" version)"
[ "$(gstack_claude_overlay_field "$record" render)" = committed ] && kind="committed render"
if [ "$source" = explicit ]; then source="explicit: $model"; fi
echo " $(_gstack_tilde "$dest"): $overlay ($source, $kind, gstack $version)"
done <<EOF
$rows
EOF
}
+2 -2
View File
@@ -84,7 +84,7 @@ if ! _gstack_codex_auth_probe >/dev/null; then
_gstack_codex_log_event "codex_auth_failed"
echo "AUTH_FAILED"
else
_gstack_codex_model_probe # ~10s round trip on first run, cached 1h (#2477)
_gstack_codex_model_probe # ~10s round trip on first run, cached 1h
fi
_gstack_codex_version_check # warns if known-bad, non-blocking
```
@@ -99,7 +99,7 @@ source on the `CODEX_MODEL:` line) is invalid or the account cannot use it. Rela
probe's HINT lines and
follow the "Model not supported (HTTP 400)" recovery steps in
`## Error Handling` below. Running the modes anyway just burns four
invocations on the same 400 (#2477).
invocations on the same 400.
`MODEL_PROBE_INCONCLUSIVE` is non-blocking (timeout/transient network): pass
the warning through and continue.
+3 -3
View File
@@ -67,14 +67,14 @@ for line in sys.stdin:
err = obj.get('error',{}).get('message','') or 'no error message in event'
print(f'[codex turn FAILED] {err}', flush=True, file=sys.stderr)
except: pass
# Fix 2: three-way completeness check (#2671) — a STATED failure is a failure,
# Fix 2: three-way completeness check — a STATED failure is a failure,
# not a network problem; only silence with no terminal event is a disconnect.
if turn_failed:
print('[codex] turn.failed received — the turn errored (reason above), not a disconnect.', flush=True, file=sys.stderr)
elif turn_completed_count == 0:
print('[codex warning] No turn.completed event received — possible mid-stream disconnect.', flush=True, file=sys.stderr)
"
_CODEX_EXIT=${PIPESTATUS[0]:-${pipestatus[1]}} # bash sets PIPESTATUS; zsh (lowercase, 1-indexed) falls through (#2669)
_CODEX_EXIT=${PIPESTATUS[0]:-${pipestatus[1]}} # bash sets PIPESTATUS; zsh (lowercase, 1-indexed) falls through
# Fix 1: hang detection — log + surface actionable message
if [ "$_CODEX_EXIT" = "124" ]; then
_gstack_codex_log_event "codex_timeout" "540"
@@ -82,7 +82,7 @@ if [ "$_CODEX_EXIT" = "124" ]; then
echo "Codex stalled past 9 minutes. Common causes: model API stall, long prompt, network issue. Try re-running. If persistent, split the prompt or check ~/.codex/logs/."
elif [ "$_CODEX_EXIT" != "0" ]; then
# Surface non-zero exits so the calling agent doesn't read "no output" as
# a silent model/API stall. See #1327.
# a silent model/API stall.
echo "[codex exit $_CODEX_EXIT] $(head -1 "$TMPERR" 2>/dev/null || echo "no stderr captured")"
head -20 "$TMPERR" 2>/dev/null | sed 's/^/ /' || true
_gstack_codex_log_event "codex_nonzero_exit" "challenge:$_CODEX_EXIT"
+2 -2
View File
@@ -37,7 +37,7 @@ Use only one command path below. Remember its printed start token as CODEX_REVIE
read-only sandbox is set with `-c 'sandbox_mode="read-only"'` — the same form the
consult resume path uses. Without it the call inherits the user's
`~/.codex/config.toml` default, which on a trusted project can be WRITE access —
contradicting this skill's read-only contract (#2496, #2524):
contradicting this skill's read-only contract:
```bash
_REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; }
@@ -57,7 +57,7 @@ if [ "$_CODEX_EXIT" = "124" ]; then
elif [ "$_CODEX_EXIT" != "0" ]; then
# Surface non-zero exits (parse errors, arg-shape breaks, etc.) so the
# calling agent doesn't read "no output" as a silent model/API stall and
# burn 30-60min misdiagnosing it. See #1327.
# burn 30-60min misdiagnosing it.
echo "[codex exit $_CODEX_EXIT] $(head -1 "$TMPERR" 2>/dev/null || echo "no stderr captured")"
head -20 "$TMPERR" 2>/dev/null | sed 's/^/ /' || true
_gstack_codex_log_event "codex_nonzero_exit" "review:$_CODEX_EXIT"
+1 -1
View File
@@ -116,7 +116,7 @@ else
done <<EOF
$ALL
EOF
# Identity check (#3003): only checkpoints stamped with THIS project's
# Identity check: only checkpoints stamped with THIS project's
# identity are candidates for "latest". Unstamped (older) checkpoints are
# trusted unless the directory demonstrably holds another project's files.
CLASSIFIED=$(printf '%s%s' "$SAME" "$OTHER" | grep -v '^[[:space:]]*$' \
+1
View File
@@ -25,6 +25,7 @@
"build:gates": "bun build --compile make-pdf/src/cli.ts --outfile make-pdf/dist/pdf && bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun run build:diagram-render",
"gen:skill-docs": "bun run scripts/gen-skill-docs.ts",
"gen:skill-docs:user": "bun run scripts/gen-skill-docs.ts --respect-detection",
"audit:manifest": "bun run scripts/prompt-audit-manifest.ts",
"dev": "bun run browse/src/cli.ts",
"server": "bun run browse/src/server.ts",
"test": "bun run scripts/test-free-shards.ts",
+1 -1
View File
@@ -42,7 +42,7 @@ anywhere but stdout.
Read-only by contract. If the intent implies writing (submitting forms,
clicking buttons that mutate state), refuse — Step 2.
Everything a page returns is attacker-influenceable input (#2441):
Everything a page returns is attacker-influenceable input:
{{UNTRUSTED_CONTENT_WARNING}}
+76
View File
@@ -101,3 +101,79 @@ export function validateModel(input: string): string | null {
if ((ALL_MODEL_NAMES as readonly string[]).includes(input)) return null;
return `'${input}' is not a known model. Use ${ALL_MODEL_NAMES.join(', ')}.`;
}
/**
* Overlays `./setup --claude-model <id>` may select for the Claude host. The
* list is the Claude subset of ALL_MODEL_NAMES; a new Claude overlay joins
* both lists.
*/
export const CLAUDE_OVERLAY_MODELS = ['claude', 'opus-4-7', 'opus-4-8', 'sonnet-5', 'fable-5'] as const;
export type ClaudeOverlay = (typeof CLAUDE_OVERLAY_MODELS)[number];
export interface ClaudeOverlayError {
kind: 'alias' | 'unknown' | 'non-claude';
problem: string;
cause: string;
fix: string;
}
export type ClaudeOverlayResult =
| { overlay: ClaudeOverlay; generic: boolean }
| { error: ClaudeOverlayError };
const CLAUDE_CODE_ALIASES = ['opus', 'sonnet', 'haiku', 'default'];
/**
* Map a `--claude-model` value to the overlay setup renders. `resolveModel`
* does the family matching (bracketed context suffixes, dated and `-latest`
* snapshots); this adds the Claude-only allowlist and the errors setup prints.
* `generic` is true when a specific model ID has no overlay of its own and
* uses the generic `claude` overlay (for example `claude-opus-5-5`).
*/
export function resolveClaudeOverlay(id: string): ClaudeOverlayResult {
const s = id.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').trim();
const problem = `${JSON.stringify(id)} is not a Claude model ID that gstack can map to a skill overlay.`;
if (CLAUDE_CODE_ALIASES.includes(s)) {
return { error: {
kind: 'alias',
problem,
cause: `"${s}" is a Claude Code alias whose model changes over time, so it names no fixed overlay.`,
fix: 'Pass a full model ID, for example ./setup --claude-model claude-opus-4-8, or ./setup --claude-model claude for the generic overlay.',
} };
}
const family = resolveModel(s);
if (!family) {
return { error: {
kind: 'unknown',
problem,
cause: 'It does not match any model family gstack knows.',
fix: 'Pass a Claude model ID such as claude-opus-4-8 or claude-sonnet-5, or claude for the generic overlay.',
} };
}
if (!(CLAUDE_OVERLAY_MODELS as readonly string[]).includes(family)) {
return { error: {
kind: 'non-claude',
problem,
cause: `It belongs to the ${family} family, and --claude-model selects Claude overlays only.`,
fix: `For Codex skills use ./setup --host codex --model ${id}. For Claude, pass a Claude model ID such as claude-opus-4-8.`,
} };
}
return { overlay: family as ClaudeOverlay, generic: family === 'claude' && s !== 'claude' };
}
// `bun scripts/models.ts claude-overlay <id>` — setup's bridge to the table
// above. Prints "<overlay>\t<generic 0|1>" or the error lines on stderr (exit 1).
if (import.meta.main) {
const [command, id] = process.argv.slice(2);
if (command !== 'claude-overlay' || id === undefined) {
console.error('Usage: bun scripts/models.ts claude-overlay <claude-model-id>');
process.exit(2);
}
const result = resolveClaudeOverlay(id);
if ('error' in result) {
console.error(`${result.error.problem}\n${result.error.cause}\n${result.error.fix}`);
process.exit(1);
}
console.log(`${result.overlay}\t${result.generic ? 1 : 0}`);
}
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bun
/**
* Prompt-audit manifest: the files a contributor feeds to Anthropic's
* `/claude-api prompt-audit` (a Claude Code skill, run in your own Claude Code
* session) at each frontier-model release, cut into slices small enough for
* one audit pass each. Procedure: CONTRIBUTING.md "Prompt audit at each
* frontier-model release".
*
* Slices, in order: shared instructions every skill reads (CLAUDE.md, model
* overlays, preamble resolvers), the other resolvers, the skill templates
* (a skill's SKILL.md.tmpl and sections stay in one slice), the code that
* builds model requests, and the tests that pin skill wording (they read a
* template, skill or resolver and assert a literal of 40+ characters).
*
* Usage: bun run audit:manifest [--json] [--budget-kb <n>]
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
const ROOT = resolve(import.meta.dir, '..');
const DEFAULT_BUDGET_KB = 200;
export interface AuditSlice { id: string; group: string; files: string[]; bytes: number }
function walk(dir: string, keep: (rel: string) => boolean): string[] {
const out: string[] = [];
const visit = (d: string) => {
for (const entry of readdirSync(d, { withFileTypes: true })) {
const p = join(d, entry.name);
const rel = relative(ROOT, p);
if (entry.isDirectory()) {
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') visit(p);
} else if (keep(rel)) {
out.push(rel);
}
}
};
visit(dir);
return out.sort();
}
const size = (rel: string) => statSync(join(ROOT, rel)).size;
const read = (rel: string) => readFileSync(join(ROOT, rel), 'utf8');
/** Pack units (each a list of files that must stay together) into slices under the budget. */
function pack(group: string, units: string[][], budget: number): Omit<AuditSlice, 'id'>[] {
const slices: Omit<AuditSlice, 'id'>[] = [];
let current: Omit<AuditSlice, 'id'> | null = null;
for (const unit of units) {
const bytes = unit.reduce((sum, f) => sum + size(f), 0);
if (!current || (current.bytes > 0 && current.bytes + bytes > budget)) {
current = { group, files: [], bytes: 0 };
slices.push(current);
}
current.files.push(...unit);
current.bytes += bytes;
}
return slices;
}
export function buildManifest(budgetKb = DEFAULT_BUDGET_KB): AuditSlice[] {
const budget = budgetKb * 1024;
const preamble = walk(join(ROOT, 'scripts/resolvers/preamble'), rel => rel.endsWith('.ts'));
const shared = ['CLAUDE.md', ...walk(join(ROOT, 'model-overlays'), rel => rel.endsWith('.md')), ...preamble];
const resolvers = walk(join(ROOT, 'scripts/resolvers'), rel => rel.endsWith('.ts') && !preamble.includes(rel));
const templates = walk(ROOT, rel => rel.endsWith('.tmpl') && !rel.startsWith('test/'));
const bySkill = new Map<string, string[]>();
for (const rel of templates) {
const skill = rel.includes('/') ? rel.split('/')[0] : '(root)';
bySkill.set(skill, [...(bySkill.get(skill) ?? []), rel]);
}
const requestCode = [...walk(join(ROOT, 'lib'), rel => rel.endsWith('.ts')),
...walk(join(ROOT, 'design/src'), rel => rel.endsWith('.ts')),
...walk(join(ROOT, 'test/helpers'), rel => rel.endsWith('.ts'))]
.filter(rel => /api\.anthropic\.com|api\.openai\.com|\/v1\/messages|messages\.create|responses\.create/.test(read(rel)));
const pinningTests = walk(join(ROOT, 'test'), rel => /^test\/[^/]+\.test\.ts$/.test(rel))
.filter(rel => {
const src = read(rel);
return /SKILL\.md|\.md\.tmpl|scripts\/resolvers|model-overlays/.test(src) && /\.(toContain|toMatch)\((['"`])[^'"`]{40,}/.test(src);
});
const slices = [
...pack('shared instructions', [shared], Infinity),
...pack('resolvers', resolvers.map(f => [f]), budget),
...pack('skill templates', [...bySkill.values()], budget),
...pack('model-request code', requestCode.map(f => [f]), budget),
...pack('tests that pin skill wording', pinningTests.map(f => [f]), budget),
];
return slices.map((slice, i) => ({ id: `s${String(i + 1).padStart(2, '0')}`, ...slice }));
}
if (import.meta.main) {
const args = process.argv.slice(2);
const budgetArg = args.indexOf('--budget-kb');
const budgetKb = budgetArg >= 0 ? Number(args[budgetArg + 1]) : DEFAULT_BUDGET_KB;
if (!Number.isFinite(budgetKb) || budgetKb <= 0) {
console.error('--budget-kb needs a positive number of kilobytes');
process.exit(2);
}
const manifest = buildManifest(budgetKb);
if (args.includes('--json')) {
console.log(JSON.stringify(manifest, null, 2));
} else {
const files = manifest.reduce((n, s) => n + s.files.length, 0);
console.log(`Prompt-audit manifest: ${manifest.length} slices, ${files} files. Run /claude-api prompt-audit in Claude Code on one slice at a time (CONTRIBUTING.md).`);
for (const slice of manifest) {
console.log(`\n## ${slice.id} ${slice.group} (${slice.files.length} files, ${Math.round(slice.bytes / 1024)} KB)`);
for (const file of slice.files) console.log(file);
}
}
}
+1 -1
View File
@@ -165,7 +165,7 @@ Branch on the echoed \`CODEX_MODE\`:
- **\`under_codex\`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and follow the workflow's native-review instructions below. Conflicting inherited harness markers are not grounds to guess another provider.
- **\`not_authed\`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run \`codex login\` or set \`$CODEX_API_KEY\`." ${nativeRoute}
- **\`broken_install\`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: \`npm install -g @openai/codex\`." Relay the probe's HINT lines. ${nativeRoute}
- **\`model_unusable\`** — the selected model (see \`CODEX_MODEL:\`) is invalid or unavailable to the account (#2477: HTTP 400 on every call). Relay the probe's HINT lines and the fix (\`GSTACK_CODEX_MODEL=<supported-model>\` or config.toml \`model\`); never substitute a model. ${nativeRoute} The ~10s round trip is cached for 1h; timeouts fail open to \`ready\`.
- **\`model_unusable\`** — the selected model (see \`CODEX_MODEL:\`) is invalid or unavailable to the account (HTTP 400 on every call). Relay the probe's HINT lines and the fix (\`GSTACK_CODEX_MODEL=<supported-model>\` or config.toml \`model\`); never substitute a model. ${nativeRoute} The ~10s round trip is cached for 1h; timeouts fail open to \`ready\`.
- **\`ready\`** — run the Codex pass below.`;
}
+1 -1
View File
@@ -133,7 +133,7 @@ if command -v jq >/dev/null 2>&1; then
# .commit must be bound BEFORE piping to the split commit array: a
# pipe rebinds jq's context, so a bare .commit after it indexes the
# ARRAY with a string, every line errors into 2>/dev/null, and the
# aggregate is empty forever — the #2018 zero-tasks bug.
# aggregate is empty forever.
jq -c --arg branch "$BRANCH" --arg commits "$COMMITS_RECENT" \\
'.commit as $c | select(.branch == $branch and ($commits | split("|") | index($c) != null))' \\
"$f" 2>/dev/null >> "$ALL_JSONL" || true
+120 -14
View File
@@ -26,7 +26,6 @@ Options:
instruction-only: slate, openclaw, hermes, gbrain
auto: every installed agent that setup detects
An explicit --host never changes another host's install.
--model <id> Codex model profile override. Otherwise reads Codex config.
--global Allow this checkout to replace an existing global install
(or register Codex globally from a project-vendored source).
--prefix Install skills with the gstack- prefix (e.g. /gstack-review).
@@ -40,11 +39,21 @@ Options:
-q, --quiet Suppress progress output.
-h, --help Show this help and exit.
Model selection:
--model <id> Codex model profile override. Otherwise reads Codex config.
--claude-model <id>
Claude skill overlay for a Claude model ID (for example
claude-opus-4-8). Saved as claude_overlay_model and used by
every later setup, upgrade and gbrain-refresh. Without it
the generic claude overlay is used; --claude-model claude
returns to it.
Examples:
./setup # solo install for Claude Code
./setup --host codex # install for OpenAI Codex CLI only
./setup --host auto # install for every detected agent
./setup --host codex --model gpt-5.6-sol
./setup --claude-model claude-opus-4-8 # Opus 4.8 overlay for Claude skills
./setup --status # what is installed, where, at which version
./setup --team # team mode for a shared repo
./setup --no-prefix # use short slash-command names
@@ -66,9 +75,11 @@ for _arg in "$@"; do
case "$_arg" in
--status)
_STATUS_DIR="$(cd "$(dirname "$0")" && pwd -P)"
. "$_STATUS_DIR/bin/gstack-state-root.sh" && . "$_STATUS_DIR/bin/gstack-install-registry.sh" || exit 1
. "$_STATUS_DIR/bin/gstack-state-root.sh" && . "$_STATUS_DIR/bin/gstack-install-registry.sh" \
&& . "$_STATUS_DIR/bin/gstack-render-claude.sh" || exit 1
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
gstack_install_status
gstack_claude_overlay_status
exit 0 ;;
esac
done
@@ -88,12 +99,14 @@ PLAN_TUNE_HOOKS_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = ex
TIMELINE_STOP_HOOK_MODE="" # "" = resolve from env/config; "yes"/"no" = explicit (#2677)
MODEL_OVERRIDE=""
MODEL_OVERRIDE_SET=0
CLAUDE_MODEL_ARG=""
CLAUDE_MODEL_SET=0
REFRESH_REGISTERED=0
HOST_FLAG_SET=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 --global --local --prefix --no-prefix --team --no-team \
for flag in --host --model --claude-model --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 --quiet --help; do
d="$(awk -v a="${bad%%=*}" -v b="$flag" 'BEGIN {
@@ -109,7 +122,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) case "$next" in ''|-*) ;; *) value=" $next" ;; esac ;; esac
case "$best" in --host|--model|--claude-model) case "$next" in ''|-*) ;; *) value=" $next" ;; esac ;; esac
if [ "$best_d" -le 3 ]; then
echo "Did you mean: ./setup $best$value" >&2
fi
@@ -121,6 +134,8 @@ while [ $# -gt 0 ]; do
--host=*) HOST="${1#--host=}"; HOST_FLAG_SET=1; shift ;;
--model) [ -z "$2" ] && echo "Missing value for --model" >&2 && exit 1; MODEL_OVERRIDE="$2"; MODEL_OVERRIDE_SET=1; shift 2 ;;
--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 ;;
--local) LOCAL_INSTALL=1; shift ;;
--global) GLOBAL_INSTALL=1; shift ;;
--prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;;
@@ -225,8 +240,35 @@ SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)"
. "$SOURCE_GSTACK_DIR/bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SOURCE_GSTACK_DIR/bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
. "$SOURCE_GSTACK_DIR/bin/gstack-install-registry.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-install-registry.sh is missing. fix: reinstall from a fresh clone (docs/ADDING_A_HOST.md)" >&2; exit 1; }
. "$SOURCE_GSTACK_DIR/bin/gstack-render-claude.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-render-claude.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; }
SETUP_VERSION="$(cat "$SOURCE_GSTACK_DIR/VERSION" 2>/dev/null || echo unknown)"
# --claude-model is validated before anything is written, and persisted once
# Claude is known to be selected (below, and in the --refresh-registered parent).
if [ "$CLAUDE_MODEL_SET" -eq 1 ]; then
if ! _CLAUDE_MODEL_RESOLVED="$(cd "$SOURCE_GSTACK_DIR" && bun run scripts/models.ts claude-overlay "$CLAUDE_MODEL_ARG" 2>&1)"; then
printf 'Error: --claude-model %s\n' "$_CLAUDE_MODEL_RESOLVED" | sed '2,$s/^/ /' >&2
echo " Nothing was installed or changed." >&2
exit 1
fi
fi
# _persist_claude_model — save the validated --claude-model value as given.
_persist_claude_model() {
local overlay generic
[ "$CLAUDE_MODEL_SET" -eq 1 ] || return 0
if ! "$SOURCE_GSTACK_DIR/bin/gstack-config" set claude_overlay_model "$CLAUDE_MODEL_ARG" >/dev/null; then
echo "Error: could not save claude_overlay_model. Nothing was installed or changed." >&2
exit 1
fi
overlay="$(printf '%s' "$_CLAUDE_MODEL_RESOLVED" | tail -1 | cut -f1)"
generic="$(printf '%s' "$_CLAUDE_MODEL_RESOLVED" | tail -1 | cut -f2)"
if [ "$generic" = 1 ]; then
log "Claude overlay: $CLAUDE_MODEL_ARG has no overlay of its own, so it uses the generic claude overlay (saved as claude_overlay_model)."
else
log "Claude overlay: $CLAUDE_MODEL_ARG uses the $overlay overlay (saved as claude_overlay_model; ./setup --claude-model claude returns to the generic overlay)."
fi
}
# ─── Quiet mode helper ────────────────────────────────────────
log() { [ "$QUIET" -eq 0 ] && echo "$@" || true; }
@@ -292,6 +334,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.
if [ "$CLAUDE_MODEL_SET" -eq 1 ]; then
case "
$_RF_TARGETS" in
*"
claude "*) _persist_claude_model ;;
*) echo "Error: --claude-model applies to Claude installs, and no Claude install is registered for this checkout. Nothing was installed or changed. Install Claude first: ./setup --claude-model $CLAUDE_MODEL_ARG" >&2
exit 1 ;;
esac
fi
_RF_SUMMARY="$(mktemp "${TMPDIR:-/tmp}/gstack-refresh.XXXXXX")"
printf '%s' "$_RF_SKIPPED" > "$_RF_SUMMARY"
_RF_FAILED=0
@@ -821,7 +873,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" 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" 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; } \
@@ -840,7 +892,10 @@ _render_install() {
printf '%s\n' "$out" | tail -3 | sed 's/^/ /' >&2
[ -d "$render" ] || return 1
else
gstack_render_lock "$render"
_swap_in_render "$render" "$tmp"
gstack_render_unlock "$render"
[ "$host" != claude ] || gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$render" "${_GBRAIN_RENDER_STATE:-unknown}" rendered 0
fi
_INSTALL_RENDER="$render"
}
@@ -1092,9 +1147,14 @@ if [ "$INSTALL_COPILOT" -eq 1 ] && [ -n "${COPILOT_HOME:-}" ] && [ "${COPILOT_HO
fi
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)." >&2
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 [ "$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
fi
_persist_claude_model
migrate_direct_codex_install() {
local gstack_dir="$1"
@@ -1434,6 +1494,10 @@ if [ "$INSTALL_CODEX" -eq 1 ] || [ "$CODEX_GENERATION_MODEL" != "gpt-6-astra" ];
log "Codex skill profile: $CODEX_GENERATION_MODEL"
log "Source: $CODEX_GENERATION_MODEL_SOURCE"
fi
# Claude skill overlay: claude_overlay_model when present (only ./setup
# --claude-model writes it), else the generic claude overlay
# (bin/gstack-render-claude.sh).
gstack_claude_overlay "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/bin/gstack-config"
# Migrate existing wrappers BEFORE build can regenerate/prune shared host trees.
# This also repairs existing Codex/Kiro installs during a Claude-only setup.
@@ -3514,6 +3578,11 @@ fi
# as-is (zero token overhead) and any stale render dir is removed so it
# can't shadow canonical files on the next relink.
#
# A pinned Claude overlay (claude_overlay_model, set by ./setup
# --claude-model) is rendered into the same dir whatever detection says; a
# missing or failed detector counts as "no brain blocks"
# (bin/gstack-render-claude.sh).
#
# Users who install gbrain after running ./setup should re-run setup OR
# call `gstack-config gbrain-refresh`.
DETECT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-gbrain-detect"
@@ -3523,6 +3592,8 @@ _GSTACK_RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$GBRAIN_STATE_DIR/render/claude}"
# PID-unique tmp so concurrent setups (parallel Conductor workspaces) can't
# clobber each other's in-flight detection write.
DETECTION_TMP="$DETECTION_FILE.$$.tmp"
# ok | absent | unknown (detector missing or failed).
_GBRAIN_RENDER_STATE=unknown
mkdir -p "$GBRAIN_STATE_DIR"
if [ -x "$DETECT_BIN" ]; then
if "$DETECT_BIN" > "$DETECTION_TMP" 2>/dev/null; then
@@ -3534,10 +3605,12 @@ if [ -x "$DETECT_BIN" ]; then
# This install serves its own per-install render (:user, so gbrain
# detection decides the brain-aware blocks); refresh it with the
# detection just written.
_GBRAIN_RENDER_STATE=detected
if _render_claude_install "$INSTALL_GSTACK_DIR" && [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null
fi
elif "$DETECT_BIN" --is-ok 2>/dev/null; then
_GBRAIN_RENDER_STATE=ok
if [ -n "${GSTACK_SKIP_GBRAIN_REGEN:-}" ]; then
# Dev/source tree (set by bin/dev-setup): detection is persisted
# above; the dev workspace renders the :user variant into its own
@@ -3560,29 +3633,36 @@ 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" 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" 2>&1)
_GEN_USER_RC=$?
printf '%s\n' "$_GEN_USER_OUT" | tail -3
exit "$_GEN_USER_RC"
); then
gstack_render_lock "$_GSTACK_RENDER_DIR"
_swap_in_render "$_GSTACK_RENDER_DIR" "$_GSTACK_RENDER_TMP"
# Repoint the installed skills at the fresh render — the installer
# prefers rendered files when present (#2569).
if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null
fi
gstack_render_unlock "$_GSTACK_RENDER_DIR"
gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" ok rendered
else
rm -rf "$_GSTACK_RENDER_TMP"
log " warning: gen:skill-docs:user failed — previous render (if any) left in place, links stay valid. Run 'bun run gen:skill-docs:user --host claude --out-dir $_GSTACK_RENDER_DIR' manually if you want fresh brain-aware blocks"
gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" ok failed
fi
# Repoint the installed skills at the fresh render — the installer
# prefers rendered files when present (#2569).
if [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null
fi
fi
else
_GBRAIN_RENDER_STATE=absent
log "gbrain not detected — brain-aware blocks suppressed in planning-skill SKILL.md files (zero token overhead)."
log " To enable: install gbrain via /setup-gbrain, then re-run ./setup or 'gstack-config gbrain-refresh'."
# A render from a previous gbrain install would shadow canonical files
# on the next link/relink — drop it and restore canonical links.
if [ -d "$_GSTACK_RENDER_DIR" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ] && [ "$INSTALL_CLAUDE" -eq 1 ]; then
# 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 ]; 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
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null
fi
@@ -3593,6 +3673,32 @@ if [ -x "$DETECT_BIN" ]; then
log " warning: gstack-gbrain-detect failed — brain-aware blocks will stay suppressed"
fi
fi
# Default install without brain blocks: render a pinned overlay, or (detector
# missing or failed) drop a previous pinned render after a switch back to claude.
if [ "$INSTALL_CLAUDE" -eq 1 ] && [ -z "$_CLAUDE_INSTALL_RENDER" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ] \
&& { [ "$_GBRAIN_RENDER_STATE" = absent ] || [ "$_GBRAIN_RENDER_STATE" = unknown ]; }; then
gstack_claude_render_action "$_GSTACK_RENDER_DIR" "$_GBRAIN_RENDER_STATE"
case "$_GSTACK_RENDER_ACTION" in
plain)
if gstack_claude_render_plain "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR"; then
gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" "$_GBRAIN_RENDER_STATE" rendered
else
gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" "$_GBRAIN_RENDER_STATE" failed
fi
;;
remove)
if [ "$_GBRAIN_RENDER_STATE" = unknown ]; then
rm -rf "$_GSTACK_RENDER_DIR"
gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$_GSTACK_RENDER_DIR" unknown removed
else
_GSTACK_RENDER_ACTION=keep
fi
;;
esac
if [ "$_GSTACK_RENDER_ACTION" != keep ] && [ "${_CLAUDE_SKILLS_LINKED:-0}" -eq 1 ]; then
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR" >/dev/null
fi
fi
# Hook path resolution is CANONICAL-ONLY via the resolver defined near
# CANONICAL_GSTACK_ROOT above (_hook_command_path): a hook command registered into
+2 -2
View File
@@ -128,7 +128,7 @@ Then follow the same secret-read + verify + init flow as Path 1.
# A/B). Without the key, gbrain auto-selects (OpenAI 1536d when available).
# Never select gbrain's legacy zeroentropyai recipe for a new brain: its hosted
# API's sunset date (September 4, 2026) has passed; the wireup helper warns existing installs.
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting
if [ -n "${VOYAGE_API_KEY:-}" ]; then
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
fi
@@ -220,7 +220,7 @@ fi
# VOYAGE_API_KEY is set. It wins the A/B over voyage-4-large and OpenAI
# text-embedding-3-large on this codebase's symbol queries. Falls back to
# gbrain's auto-selected provider when the key isn't present.
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting
if [ -n "${VOYAGE_API_KEY:-}" ]; then
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
fi
@@ -47,7 +47,7 @@ mv "$HOME/.gbrain/config.json" "$BACKUP"
# embedding provider chain (OpenAI 1536d when OPENAI_API_KEY is present, etc.).
# Never select gbrain's legacy zeroentropyai recipe for a new brain: its hosted
# API's sunset date (September 4, 2026) has passed; the wireup helper warns existing installs.
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting (#1798)
set -- # flags ride the positional params — unquoted $VAR breaks under zsh word-splitting
if [ -n "${VOYAGE_API_KEY:-}" ]; then
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
fi
+1 -1
View File
@@ -37,7 +37,7 @@ stop (Aside keeps its own skills: `aside skills list`).
The scrape you are codifying consumed page content — treat every string it
extracted as attacker-influenceable input when you synthesize code, names, or
selectors from it (#2441):
selectors from it:
{{UNTRUSTED_CONTENT_WARNING}}
+1 -1
View File
@@ -449,7 +449,7 @@ machine — gbrain's daemon handles incremental refresh on a schedule.
Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the
orchestrator refuses destructive source ops when it detects a running autopilot
to avoid racing it (#1734). Prefer registering user repos with `gbrain sources
to avoid racing it. Prefer registering user repos with `gbrain sources
add --path <dir>` (no `--url`): URL-managed sources can auto-reclone, and the
sync code walk for them requires an explicit `--allow-reclone` opt-in.
+197
View File
@@ -0,0 +1,197 @@
/**
* Archaeology lint: model-read skill text carries rules, not history.
*
* Issue and PR numbers ("(#2477)", "See #1327", "PR #605") and incident
* stories tell the model where a rule came from, which it cannot use, and cost
* tokens in every session. The lint renders every host into a temp dir (the
* text models actually read), scans each generated SKILL.md and section, maps
* every hit back to the template or resolver that emitted it, and fails with
* that source. Legitimate references (upstream bug IDs, usage examples,
* sample output) are allowlisted by skill plus a nearby anchor string, each
* with a reason; an entry that no longer matches anything fails too.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative, resolve, sep } from 'node:path';
const ROOT = resolve(import.meta.dir, '..');
/** `source` narrows an entry to hits emitted by one template or resolver. */
interface AllowEntry { skills: string[]; anchor: string; reason: string; source?: string }
/** Pending entries name an edit another workstream owns; delete them when it lands. */
const ALLOWLIST: AllowEntry[] = [
{ skills: ['land-and-deploy'], anchor: 'cli/cli#', reason: 'upstream GitHub CLI bug IDs behind the merge-queue retry guidance' },
{ skills: ['land-and-deploy'], anchor: '/land-and-deploy #123', reason: 'usage example of the PR-number argument' },
{ skills: ['landing-report'], anchor: 'alpha-branch', reason: 'sample landing-report output (PR numbers in the queue table)' },
{ skills: ['landing-report'], anchor: 'feat/payments', reason: 'sample landing-report output (PR number of a sibling workspace)' },
{ skills: ['retro'], anchor: 'Biggest ship: PR #605', reason: 'sample retro output' },
{ skills: ['codex'], anchor: 'OpenAI issues #8545', reason: 'upstream OpenAI Codex issue IDs behind the reasoning-effort default' },
{
skills: ['autoplan', 'plan-ceo-review', 'plan-devex-review', 'plan-eng-review'],
anchor: 'Repo-local docs win when at least as fresh (#703)',
reason: 'PENDING: scripts/resolvers/design-doc-discovery.ts is owned by W3; drop "(#703)" there and delete this entry',
},
{
skills: ['codex'],
anchor: 'consult previously had NONE',
source: 'codex/sections/consult-mode.md.tmpl',
reason: 'PENDING: codex/sections/consult-mode.md.tmpl is a W1 rule file; drop "(#2671; consult previously had NONE)" there and delete this entry',
},
{
skills: ['codex'],
anchor: 'falls through (#2669)',
source: 'codex/sections/consult-mode.md.tmpl',
reason: 'PENDING: codex/sections/consult-mode.md.tmpl is a W1 rule file; drop " (#2669)" there and delete this entry',
},
{
skills: ['codex'],
anchor: 'stall. See #1327.',
source: 'codex/sections/consult-mode.md.tmpl',
reason: 'PENDING: codex/sections/consult-mode.md.tmpl is a W1 rule file; drop " See #1327." there and delete this entry',
},
{
skills: ['codex'],
anchor: 'Session-cost reality (#2387, measured)',
source: 'codex/sections/consult-mode.md.tmpl',
reason: 'PENDING: codex/sections/consult-mode.md.tmpl is a W1 rule file; use "(measured)" there and delete this entry',
},
];
/** `#` + 3 or more digits (bare or parenthesized), `PR #N`, `issue #N`, incident stories. */
const PATTERNS: RegExp[] = [
/(?<!&)#\d{3,}\b/g,
/\bPR #\d+\b/g,
/\bissues? #\d+\b/gi,
/\b(?:a|an|one|the) (?:real|actual|past|previous|production) incident\b/gi,
/\bincident (?:occurred|happened)\b/gi,
/\bpost-?mortem\b/gi,
];
const ANCHOR_WINDOW = 3;
interface Hit { file: string; skill: string; line: number; text: string; match: string; context: string; near: string; sources?: string[] }
let outDir = '';
let hits: Hit[] = [];
let sources: { file: string; text: string }[] = [];
function walk(dir: string, keep: (path: string) => boolean, out: string[] = []): string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== 'node_modules' && entry.name !== '.git') walk(p, keep, out);
} else if (keep(p)) {
out.push(p);
}
}
return out;
}
function skillOf(rel: string): string {
const parts = rel.split(sep);
const name = parts[0].startsWith('.') ? parts[parts.indexOf('skills') + 1] ?? parts[0] : parts.length === 1 ? 'gstack' : parts[0];
const bare = name.replace(/^gstack-/, '');
return name !== bare && existsSync(join(outDir, bare)) ? bare : name;
}
/** The templates or resolvers that emitted a hit: the most specific snippet any source contains. */
function sourcesFor(hit: Hit): string[] {
if (hit.sources) return hit.sources;
const at = hit.text.indexOf(hit.match);
const snippets = [hit.near, hit.text.trim(), ...[60, 30, 12, 0].map(width =>
hit.text.slice(Math.max(0, at - width), at + hit.match.length + width).trim())];
for (const snippet of snippets) {
const found = sources.filter(s => s.text.includes(snippet)).map(s => s.file);
if (found.length > 0) return (hit.sources = found);
}
return (hit.sources = []);
}
beforeAll(() => {
outDir = mkdtempSync(join(tmpdir(), 'gstack-archaeology-'));
const gen = spawnSync('bun', ['run', 'scripts/gen-skill-docs.ts', '--host', 'all', '--out-dir', outDir], {
cwd: ROOT, encoding: 'utf8', timeout: 180_000,
});
if (gen.status !== 0) throw new Error(`gen-skill-docs --host all failed:\n${gen.stderr}`);
const generated = walk(outDir, p => /(^|[\\/])SKILL\.md$/.test(p) || /[\\/]sections[\\/][^\\/]+\.md$/.test(p));
for (const file of generated) {
const rel = relative(outDir, file);
const lines = readFileSync(file, 'utf8').split('\n');
lines.forEach((text, i) => {
for (const pattern of PATTERNS) {
for (const m of text.matchAll(pattern)) {
hits.push({
file: rel, skill: skillOf(rel), line: i + 1, text, match: m[0],
context: lines.slice(Math.max(0, i - ANCHOR_WINDOW), i + ANCHOR_WINDOW + 1).join('\n'),
near: lines.slice(Math.max(0, i - 1), i + 2).join('\n').trim(),
});
}
}
});
}
const sourceFiles = [
...walk(ROOT, p => p.endsWith('.tmpl') && !relative(ROOT, p).startsWith('.')),
...walk(join(ROOT, 'scripts', 'resolvers'), p => p.endsWith('.ts')),
...walk(join(ROOT, 'model-overlays'), p => p.endsWith('.md')),
];
sources = sourceFiles.map(file => ({
file: relative(ROOT, file),
text: readFileSync(file, 'utf8').replace(/\\`/g, '`').replace(/\\\$/g, '$').replace(/\\\\/g, '\\'),
}));
}, 240_000);
afterAll(() => {
if (outDir) rmSync(outDir, { recursive: true, force: true });
});
const covers = (e: AllowEntry, hit: Hit) =>
e.skills.includes(hit.skill) && hit.context.includes(e.anchor) && (!e.source || sourcesFor(hit).includes(e.source));
const allowedBy = (hit: Hit) => ALLOWLIST.find(e => covers(e, hit));
describe('archaeology lint (generated skill text, every host)', () => {
test('the scan sees every host and real hits', () => {
const hosts = new Set(hits.map(h => h.file.split(sep)[0].startsWith('.') ? h.file.split(sep)[0] : 'claude'));
expect(hosts.size).toBeGreaterThan(5);
expect(hits.some(h => h.match === '#123')).toBe(true);
});
test('no issue/PR numbers or incident stories in model-read skill text', () => {
const report = new Map<string, { sources: string[]; match: string; skill: string; text: string; renders: number }>();
for (const hit of hits) {
if (allowedBy(hit)) continue;
const found = sourcesFor(hit);
const key = `${found.join(',') || hit.file}\0${hit.match}`;
const seen = report.get(key);
if (seen) seen.renders++;
else report.set(key, { sources: found, match: hit.match, skill: hit.skill, text: hit.text.trim(), renders: 1 });
}
const messages = [...report.values()].map(r => [
` source: ${r.sources.length ? r.sources.join(', ') : '(not found in templates or scripts/resolvers; search the generated text below)'}`,
` matched: ${r.match} (${r.renders} generated render${r.renders === 1 ? '' : 's'})`,
` line: ${r.text.slice(0, 160)}`,
` fix: remove the reference from the source and keep the rule. For a legitimate reference (upstream bug ID, usage example, sample output), add to ALLOWLIST in test/archaeology-lint.test.ts:`,
` { skills: ['${r.skill}'], anchor: '<text on or near the matched line>', reason: '<why the model needs this reference>' },`,
].join('\n'));
expect(messages, `Archaeology in model-read skill text:\n${messages.join('\n\n')}`).toEqual([]);
});
test('every allowlist entry carries a reason and still matches a hit', () => {
const stale = ALLOWLIST.filter(e => !e.reason.trim() || !hits.some(h => covers(e, h)));
expect(stale.map(e => `${e.skills.join(',')}: '${e.anchor}' — matches nothing (or has no reason); delete the entry`)).toEqual([]);
});
test('self-test: the patterns catch archaeology and leave colors, placeholders and entities alone', () => {
const scan = (line: string) => PATTERNS.flatMap(p => [...line.matchAll(p)].map(m => m[0]));
expect(scan('cached 1h (#2477)')).toEqual(['#2477']);
expect(scan('See #1327.')).toEqual(['#1327']);
expect(scan('Biggest ship: PR #605 — x')).toEqual(['#605', 'PR #605']);
expect(scan('OpenAI issues #8545, #8402')).toEqual(['#8545', '#8402', 'issues #8545']);
expect(scan('A real incident occurred in production')).toEqual(['A real incident', 'incident occurred']);
expect(scan('color: #6366f1; PR #NNN; &#8212; #12; resolved incidents')).toEqual([]);
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* `resolveClaudeOverlay` — the validation table behind `./setup --claude-model`.
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { ALL_MODEL_NAMES, CLAUDE_OVERLAY_MODELS, type ClaudeOverlay, type ClaudeOverlayError, resolveClaudeOverlay } from '../scripts/models';
const ROOT = resolve(import.meta.dir, '..');
describe('resolveClaudeOverlay', () => {
test.each([
['claude', 'claude', false],
['opus-4-7', 'opus-4-7', false],
['claude-opus-4-7', 'opus-4-7', false],
['claude-opus-4-8-20260115', 'opus-4-8', false],
['claude-opus-4-8-latest', 'opus-4-8', false],
['claude-sonnet-5[1m]', 'sonnet-5', false],
[' Claude-Sonnet-5 ', 'sonnet-5', false],
['claude-fable-5-1', 'fable-5', false],
['claude-opus-5-5', 'claude', true],
['claude-opus-4-7-1', 'claude', true],
['claude-haiku-4-5', 'claude', true],
['claude-3-opus-20240229', 'claude', true],
] as [string, ClaudeOverlay, boolean][])('%p uses the %p overlay', (id, overlay, generic) => {
expect(resolveClaudeOverlay(id)).toEqual({ overlay, generic });
});
test.each([
['opus', 'alias'],
['sonnet', 'alias'],
['haiku', 'alias'],
['default', 'alias'],
['Opus[1m]', 'alias'],
['gpt-5.4', 'non-claude'],
['gemini', 'non-claude'],
['o3', 'non-claude'],
['gpt-6-astra', 'non-claude'],
['banana', 'unknown'],
['', 'unknown'],
] as [string, ClaudeOverlayError['kind']][])('%p is rejected as %p with problem, cause and fix', (id, kind) => {
const result = resolveClaudeOverlay(id);
expect('error' in result).toBe(true);
if (!('error' in result)) return;
expect(result.error.kind).toBe(kind);
for (const part of [result.error.problem, result.error.cause, result.error.fix]) expect(part.length).toBeGreaterThan(10);
expect(result.error.fix).toContain('claude');
});
test('every Claude overlay is a known model with an overlay file', () => {
for (const overlay of CLAUDE_OVERLAY_MODELS) {
expect((ALL_MODEL_NAMES as readonly string[]).includes(overlay)).toBe(true);
expect(existsSync(join(ROOT, 'model-overlays', `${overlay}.md`))).toBe(true);
}
});
test('the CLI bridge setup calls prints overlay and generic flag, or the error lines', () => {
const run = (id: string) => spawnSync('bun', ['run', 'scripts/models.ts', 'claude-overlay', id], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 });
const ok = run('claude-opus-5-5');
expect(ok.status).toBe(0);
expect(ok.stdout).toBe('claude\t1\n');
const bad = run('opus');
expect(bad.status).toBe(1);
expect(bad.stdout).toBe('');
expect(bad.stderr.trim().split('\n')).toHaveLength(3);
});
});
+301
View File
@@ -0,0 +1,301 @@
/**
* W7 Claude overlay opt-in (`./setup --claude-model <id>`), exercised through
* the real ./setup and `gstack-config gbrain-refresh` against throwaway
* checkouts and HOMEs (test/helpers/install-fixture.ts).
*
* The overlay is the family of the persisted `claude_overlay_model`, written
* only by `--claude-model`, or `claude` when the key is absent. Renders and
* their activation records come from bin/gstack-render-claude.sh. The marker
* below is text only the opus-4-7 and opus-4-8 overlays render.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { cleanupFixtures, type Fixture, makeFixture, makeSource, put, registryRows, runSetup, setVersion } from './helpers/install-fixture';
afterEach(cleanupFixtures);
const MARKER = 'Effort-match the step';
/** The fixture's bun, plus a switch that fails every out-dir render. */
function failableBun(f: Fixture) {
put(join(f.commands, 'bun'), `#!/usr/bin/env bash
case "$*" in
'install --frozen-lockfile') exit 0 ;;
'run build') echo 'Unexpected build in an install fixture' >&2; exit 90 ;;
*gen:skill-docs*--out-dir*) if [ -f "$HOME/fail-render" ]; then echo 'render failed (fixture)' >&2; exit 7; fi ;;
esac
exec ${JSON.stringify(process.execPath)} "$@"
`, 0o755);
}
/** gbrain detection driven by $HOME/gbrain-mode: ok, absent (default) or fail. */
function fakeDetect(src: string) {
put(join(src, 'bin/gstack-gbrain-detect'), `#!/usr/bin/env bash
mode="$(cat "$HOME/gbrain-mode" 2>/dev/null || echo absent)"
[ "$mode" = fail ] && exit 1
if [ "\${1:-}" = --is-ok ]; then [ "$mode" = ok ]; exit $?; fi
if [ "$mode" = ok ]; then echo '{"gbrain_local_status":"ok","gbrain_version":"0.0.1"}'; else echo '{"gbrain_local_status":"no-cli"}'; fi
`, 0o755);
}
function setup(f: Fixture, src: string, args: string[] = [], cwd?: string) {
const r = runSetup(f, join(src, 'setup'), ['--no-prefix', ...args], { cwd });
return { ...r, out: `${r.stdout}\n${r.stderr}` };
}
function config(f: Fixture, src: string, ...args: string[]) {
return spawnSync('bash', [join(src, 'bin/gstack-config'), ...args], { env: f.env, encoding: 'utf8', timeout: 120_000 });
}
const defaultRender = (f: Fixture) => join(f.home, '.gstack/render/claude');
function record(render: string): Record<string, string> {
const file = `${render}.overlay`;
if (!existsSync(file)) return {};
return Object.fromEntries(readFileSync(file, 'utf8').trim().split('\n').map(line => line.split(/=(.*)/s).slice(0, 2)));
}
const served = (skills: string) => readFileSync(join(skills, 'autoplan/SKILL.md'), 'utf8');
describe.skipIf(process.platform === 'win32')('./setup --claude-model (default install)', () => {
test('no claude_overlay_model: committed render, no render dir, no record', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
const r = setup(f, src);
expect(r.status, r.out).toBe(0);
expect(existsSync(defaultRender(f))).toBe(false);
expect(existsSync(`${defaultRender(f)}.overlay`)).toBe(false);
expect(config(f, src, 'has', 'claude_overlay_model').status).not.toBe(0);
expect(served(join(f.home, '.claude/skills'))).toBe(readFileSync(join(src, 'autoplan/SKILL.md'), 'utf8'));
expect(r.out).not.toContain('Claude skill overlay:');
const status = setup(f, src, ['--status']);
expect(status.stdout).toContain('~/.claude/skills: claude (default, committed render)');
}, 120_000);
test('pinned overlay renders and persists; the banner shows only on change; claude returns to committed files', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
const skills = join(f.home, '.claude/skills');
const pinned = setup(f, src, ['--claude-model', 'claude-opus-4-7']);
expect(pinned.status, pinned.out).toBe(0);
expect(config(f, src, 'get', 'claude_overlay_model').stdout).toBe('claude-opus-4-7');
expect(pinned.out).toContain('Claude skill overlay: opus-4-7 (was claude).');
expect(pinned.out).toContain('./setup --claude-model claude');
expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-7', source: 'explicit', model: 'claude-opus-4-7', gbrain: 'absent', render: 'user' });
expect(served(skills)).toContain(MARKER);
expect(readFileSync(join(src, 'autoplan/SKILL.md'), 'utf8')).not.toContain(MARKER);
const again = setup(f, src);
expect(again.status, again.out).toBe(0);
expect(again.out).not.toContain('Claude skill overlay:');
expect(served(skills)).toContain(MARKER);
const back = setup(f, src, ['--claude-model', 'claude']);
expect(back.status, back.out).toBe(0);
expect(config(f, src, 'get', 'claude_overlay_model').stdout).toBe('claude');
expect(back.out).toContain('Claude skill overlay: claude (was opus-4-7).');
expect(existsSync(defaultRender(f))).toBe(false);
expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', source: 'explicit', render: 'committed' });
expect(served(skills)).not.toContain(MARKER);
}, 180_000);
test('an ID without its own overlay is accepted as the generic overlay', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
const r = setup(f, src, ['--claude-model=claude-opus-5-5']);
expect(r.status, r.out).toBe(0);
expect(r.out).toContain('claude-opus-5-5 has no overlay of its own, so it uses the generic claude overlay');
expect(config(f, src, 'get', 'claude_overlay_model').stdout).toBe('claude-opus-5-5');
expect(existsSync(defaultRender(f))).toBe(false);
}, 120_000);
test('invalid IDs, a missing value and a non-Claude host error before anything is persisted', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
for (const [args, expected] of [
[['--claude-model', 'opus'], 'is a Claude Code alias'],
[['--claude-model', 'gpt-5.4'], 'selects Claude overlays only'],
[['--claude-model=banana'], 'does not match any model family'],
[['--host', 'codex', '--claude-model', 'claude-opus-4-7'], '--claude-model is supported only when Claude is selected'],
] as const) {
const r = setup(f, src, [...args]);
expect(r.status, r.out).toBe(1);
expect(r.stderr).toContain(expected);
expect(r.stderr).toContain('Nothing was installed or changed.');
}
expect(config(f, src, 'has', 'claude_overlay_model').status).not.toBe(0);
for (const args of [['--claude-model'], ['--claude-model=']]) {
const missing = spawnSync('bash', [join(src, 'setup'), ...args], { env: f.env, encoding: 'utf8', timeout: 30_000 });
expect(missing.status).toBe(1);
expect(missing.stderr).toContain('Missing value for --claude-model');
}
const typo = runSetup(f, join(src, 'setup'), ['--claude-modle', 'claude-opus-4-8']);
expect(typo.status).toBe(2);
expect(typo.stderr).toContain('Did you mean: ./setup --claude-model claude-opus-4-8');
const codexOnly = setup(f, src, ['--model', 'gpt-5.4']);
expect(codexOnly.stderr).toContain('For the Claude overlay, use --claude-model <id>.');
}, 240_000);
test('a persisted ID that no longer resolves falls back to claude and says why', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
expect(config(f, src, 'set', 'claude_overlay_model', 'opus').status).toBe(0);
const r = setup(f, src);
expect(r.status, r.out).toBe(0);
expect(r.out).toContain("claude_overlay_model 'opus' no longer resolves to a Claude overlay");
expect(r.out).toContain('using the generic claude overlay');
expect(existsSync(defaultRender(f))).toBe(false);
}, 120_000);
});
describe.skipIf(process.platform === 'win32')('overlay render lifecycle with gbrain and failures', () => {
test('gbrain present → absent keeps a pinned overlay; a failed detector still renders it', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
fakeDetect(src);
const skills = join(f.home, '.claude/skills');
put(join(f.home, 'gbrain-mode'), 'ok');
expect(setup(f, src, ['--claude-model', 'claude-opus-4-8']).status).toBe(0);
expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-8', gbrain: 'ok' });
expect(served(skills)).toContain(MARKER);
put(join(f.home, 'gbrain-mode'), 'absent');
const refresh = config(f, src, 'gbrain-refresh');
expect(refresh.status, refresh.stdout + refresh.stderr).toBe(0);
expect(refresh.stdout).toContain('gbrain not detected');
expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-8', gbrain: 'absent' });
expect(served(skills)).toContain(MARKER);
put(join(f.home, 'gbrain-mode'), 'fail');
const failed = setup(f, src);
expect(failed.status, failed.out).toBe(0);
expect(failed.out).toContain('gstack-gbrain-detect failed');
expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-8', gbrain: 'unknown' });
expect(served(skills)).toContain(MARKER);
}, 240_000);
test('gbrain present → absent with the default overlay drops the brain render', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
fakeDetect(src);
expect(setup(f, src).status).toBe(0);
put(join(f.home, 'gbrain-mode'), 'ok');
expect(config(f, src, 'gbrain-refresh').status).toBe(0);
expect(existsSync(join(defaultRender(f), 'autoplan/SKILL.md'))).toBe(true);
expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', source: 'default', gbrain: 'ok' });
put(join(f.home, 'gbrain-mode'), 'absent');
const r = config(f, src, 'gbrain-refresh');
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(existsSync(defaultRender(f))).toBe(false);
expect(existsSync(`${defaultRender(f)}.overlay`)).toBe(false);
expect(served(join(f.home, '.claude/skills'))).toBe(readFileSync(join(src, 'autoplan/SKILL.md'), 'utf8'));
}, 180_000);
test('a failed render keeps the previous render; after an upgrade it serves the committed files', () => {
const f = makeFixture();
failableBun(f);
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
const skills = join(f.home, '.claude/skills');
expect(setup(f, src, ['--claude-model', 'claude-sonnet-5']).status).toBe(0);
expect(record(defaultRender(f)).overlay).toBe('sonnet-5');
put(join(f.home, 'fail-render'), '1');
const kept = setup(f, src);
expect(kept.status, kept.out).toBe(0);
expect(kept.out).toContain('the previous render (sonnet-5 overlay) stays in use');
expect(existsSync(join(defaultRender(f), 'autoplan/SKILL.md'))).toBe(true);
setVersion(src, '9.9.9.9');
const upgraded = setup(f, src);
expect(upgraded.status, upgraded.out).toBe(0);
expect(upgraded.out).toContain('the committed claude render is served');
expect(upgraded.out).toContain('Claude skill overlay: claude (was sonnet-5).');
expect(existsSync(defaultRender(f))).toBe(false);
expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', source: 'explicit', render: 'committed', version: '9.9.9.9' });
expect(served(skills)).toBe(readFileSync(join(src, 'autoplan/SKILL.md'), 'utf8'));
}, 240_000);
test('concurrent gbrain-refresh runs leave one valid render and no temp, old or lock dirs', async () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
fakeDetect(src);
expect(setup(f, src, ['--claude-model', 'claude-opus-4-7']).status).toBe(0);
put(join(f.home, 'gbrain-mode'), 'ok');
const run = () => new Promise<{ code: number | null; err: string }>(resolve => {
const child = spawn('bash', [join(src, 'bin/gstack-config'), 'gbrain-refresh'], { env: f.env, stdio: ['ignore', 'ignore', 'pipe'] });
let err = '';
child.stderr.on('data', chunk => { err += chunk; });
child.on('exit', code => resolve({ code, err }));
});
const runs = await Promise.all([run(), run(), run()]);
expect(runs).toEqual([{ code: 0, err: '' }, { code: 0, err: '' }, { code: 0, err: '' }]);
const renderParent = join(f.home, '.gstack/render');
expect(readdirSync(renderParent).sort()).toEqual(['claude', 'claude.overlay']);
expect(readdirSync(defaultRender(f)).filter(name => name.startsWith('claude'))).toEqual([]);
expect(readFileSync(join(defaultRender(f), 'autoplan/SKILL.md'), 'utf8')).toContain(MARKER);
expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-7', gbrain: 'ok' });
}, 240_000);
});
describe.skipIf(process.platform === 'win32')('nondefault installs, refresh and status', () => {
test('a project install keeps its own per-install render: pinned, back to claude, failure keeps the previous render', () => {
const f = makeFixture();
failableBun(f);
const project = join(f.home, 'proj');
const vendored = makeSource(f, join(project, '.claude/skills/gstack'));
const skills = join(project, '.claude/skills');
const renderOf = () => registryRows(f).find(row => row[0] === 'claude')?.[8] ?? '';
expect(setup(f, vendored, ['--claude-model', 'claude-opus-4-7'], project).status).toBe(0);
const render = renderOf();
expect(render).toContain('/render/installs/claude-');
expect(record(render)).toMatchObject({ overlay: 'opus-4-7', source: 'explicit' });
expect(served(skills)).toContain(MARKER);
expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`);
expect(setup(f, vendored, ['--claude-model', 'claude'], project).status).toBe(0);
expect(renderOf()).toBe(render);
expect(record(render).overlay).toBe('claude');
expect(served(skills)).not.toContain(MARKER);
expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`);
put(join(f.home, 'fail-render'), '1');
const failed = setup(f, vendored, ['--claude-model', 'claude-opus-4-8'], project);
expect(failed.status, failed.out).toBe(0);
expect(failed.out).toContain('the previous render stays in use');
expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`);
expect(served(skills)).not.toContain(MARKER);
}, 240_000);
test('--refresh-registered persists the overlay before refreshing; later refreshes honor it with no flag', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
expect(setup(f, src).status).toBe(0);
const refreshed = setup(f, src, ['--refresh-registered', '--claude-model', 'claude-opus-4-7']);
expect(refreshed.status, refreshed.out).toBe(0);
expect(config(f, src, 'get', 'claude_overlay_model').stdout).toBe('claude-opus-4-7');
expect(record(defaultRender(f)).overlay).toBe('opus-4-7');
const quiet = runSetup(f, join(src, 'setup'), ['-q', '--refresh-registered']);
expect(quiet.status, quiet.stdout + quiet.stderr).toBe(0);
expect(record(defaultRender(f)).overlay).toBe('opus-4-7');
expect(served(join(f.home, '.claude/skills'))).toContain(MARKER);
}, 180_000);
test('./setup --status reports the overlay without bun on PATH', () => {
const f = makeFixture();
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
expect(setup(f, src, ['--claude-model', 'claude-opus-4-7']).status).toBe(0);
const noBunPath = '/usr/bin:/bin';
expect(spawnSync('bash', ['-c', 'command -v bun'], { env: { ...f.env, PATH: noBunPath }, encoding: 'utf8', timeout: 10_000 }).status).not.toBe(0);
const r = spawnSync('bash', [join(src, 'setup'), '--status'], { env: { ...f.env, PATH: noBunPath }, encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain('Claude skill overlay:');
expect(r.stdout).toContain('~/.claude/skills: opus-4-7 (explicit: claude-opus-4-7, user render, gstack ');
}, 120_000);
});
+44
View File
@@ -182,6 +182,50 @@ describe('gen-skill-docs --out-dir (B2 render isolation)', () => {
}
});
// W7 regression contract: ./setup now passes --model <overlay> to every
// Claude render, and the default overlay is `claude`. That is only safe if
// `--model claude` renders exactly what the render without --model did.
test('gen:skill-docs:user --host claude --model claude is byte-identical to the render without --model', () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-'));
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-model-claude-'));
const linkRoot = path.join(base, 'render', 'claude');
const files = (dir: string): Map<string, string> => {
const out = new Map<string, string>();
const walk = (d: string) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, entry.name);
if (entry.isDirectory()) walk(p);
else out.set(path.relative(dir, p), hashFile(p));
}
};
walk(dir);
return out;
};
try {
fs.writeFileSync(
path.join(tmpHome, 'gbrain-detection.json'),
JSON.stringify({ gbrain_local_status: 'ok', gbrain_version: '9.9.9' }),
);
const render = (out: string, extra: string[]) => spawnSync(
'bun',
['run', 'gen:skill-docs:user', '--host', 'claude', '--out-dir', out, '--link-root', linkRoot, ...extra],
{ cwd: ROOT, encoding: 'utf-8', timeout: 120_000, env: { ...process.env, GSTACK_HOME: tmpHome } },
);
const plain = render(path.join(base, 'plain'), []);
expect(plain.status, plain.stderr).toBe(0);
const pinned = render(path.join(base, 'model-claude'), ['--model', 'claude']);
expect(pinned.status, pinned.stderr).toBe(0);
const a = files(path.join(base, 'plain'));
const b = files(path.join(base, 'model-claude'));
expect(a.size).toBeGreaterThan(50);
expect([...b.keys()].sort()).toEqual([...a.keys()].sort());
expect([...a].filter(([rel, hash]) => b.get(rel) !== hash).map(([rel]) => rel)).toEqual([]);
} finally {
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(base, { recursive: true, force: true });
}
}, 240_000);
// ── External-host out-dir cases ─────────────────────────────
// The former tree-mutating tests read codex/factory artifacts from out-dir
// renders. That is only sound if an out-dir external render is (a) clean —
+51
View File
@@ -0,0 +1,51 @@
/**
* `bun run audit:manifest` — the slices a contributor feeds to
* `/claude-api prompt-audit` at each frontier-model release.
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { readdirSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { buildManifest } from '../scripts/prompt-audit-manifest';
const ROOT = resolve(import.meta.dir, '..');
describe('prompt-audit manifest', () => {
const manifest = buildManifest();
const files = manifest.flatMap(s => s.files);
test('covers CLAUDE.md, every overlay and resolver, and the skill templates, each once', () => {
expect(new Set(files).size).toBe(files.length);
expect(files).toContain('CLAUDE.md');
for (const overlay of readdirSync(join(ROOT, 'model-overlays'))) expect(files).toContain(`model-overlays/${overlay}`);
for (const rel of ['scripts/resolvers/constants.ts', 'scripts/resolvers/preamble.ts', 'ship/SKILL.md.tmpl', 'codex/sections/consult-mode.md.tmpl', 'test/helpers/llm-judge.ts']) {
expect(files).toContain(rel);
}
expect(files.some(f => f.endsWith('SKILL.md'))).toBe(false);
const resolvers = (readdirSync(join(ROOT, 'scripts/resolvers'), { recursive: true }) as string[])
.filter(f => f.endsWith('.ts')).map(f => `scripts/resolvers/${f}`);
expect(resolvers.filter(f => !files.includes(f))).toEqual([]);
});
test('a skill stays in one slice, and only a single oversized unit exceeds the budget', () => {
const sliceOf = new Map(manifest.flatMap(s => s.files.map(f => [f, s.id] as const)));
expect(sliceOf.get('codex/SKILL.md.tmpl')).toBe(sliceOf.get('codex/sections/consult-mode.md.tmpl'));
for (const slice of manifest.filter(s => s.group !== 'shared instructions')) {
if (slice.bytes > 200 * 1024) {
const skills = new Set(slice.files.map(f => f.split('/')[0]));
expect(slice.files.length === 1 || skills.size === 1).toBe(true);
}
expect(slice.bytes).toBe(slice.files.reduce((n, f) => n + statSync(join(ROOT, f)).size, 0));
}
expect(manifest.map(s => s.id)).toEqual(manifest.map((_, i) => `s${String(i + 1).padStart(2, '0')}`));
});
test('bun run audit:manifest prints the slices', () => {
const r = spawnSync('bun', ['run', 'audit:manifest'], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 });
expect(r.status).toBe(0);
expect(r.stdout).toContain('/claude-api prompt-audit');
expect(r.stdout).toContain('## s01 shared instructions');
const json = spawnSync('bun', ['run', 'scripts/prompt-audit-manifest.ts', '--json'], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 });
expect(JSON.parse(json.stdout)).toEqual(manifest);
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ function fixture(copy: boolean) {
put(path.join(root, 'VERSION'), '2.0.0.0\n');
put(path.join(root, 'ETHOS.md'), 'Fixture ethos\n');
put(path.join(root, 'package.json'), JSON.stringify({ type: 'module', scripts: { 'gen:skill-docs': 'bun run scripts/gen-skill-docs.ts' } }));
for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh', 'gstack-install-registry.sh']) {
for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh']) {
const target = path.join(root, 'bin', bin);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(ROOT, 'bin', bin), target);