mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
v1.91.332.0 feat(capy): remember a user's model preference (agent_model); every Capy session reads factory.md (#3634)
* feat(capy): agent_model preference via gstack-models resolve --role agent; capy/skill/gstack/factory.md
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(agent-model): pre-landing review fixes
- a broken preferences.yaml no longer blocks --request or config.yaml
- CRLF, a byte-order mark and a trailing comment still read the value
- never echo raw control or bidi bytes from an unvalidated source
- cap the bytes actually read, not only the stat size
- family names and host are case-insensitive in gstack-config too
- list passes the platform through to the agent block
- a wrapper inside the checkout names no preferences file
- docs: --check never writes the skill folder
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(changelog): restore CHANGELOG.md history that 1bec403 truncated
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: agent_model and factory.md in capy-install, model-policy, troubleshooting and capy/README
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(capy): the Captain archives its own child threads by its plan for each (GST-211, R39)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.332.0 stamp
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
ae3d58c932
commit
39378ca6d1
20 files changed
+637
-18
No files matched your search
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.332.0] - 2026-10-30
|
||||
|
||||
**gstack can remember which model you want your agents on, and every Capy session now reads a one-page summary of how crews work.** Set `agent_model` once (`opus`, another family, or an exact `provider/model`) and `gstack-models resolve --role agent` returns it whenever an agent picks a model for a lane, subagent or reviewer. Unset, every agent keeps its own model, and gstack takes no position for or against any model. A cross-family outside voice and a model under test in an eval ignore the preference on purpose. On Capy the preference lives in `preferences.yaml` beside the gstack skill in your Drive, so it survives fresh machines without being copied anywhere.
|
||||
|
||||
### Added
|
||||
- **`gstack-models resolve --role agent [--request <model>]`**: the agent role. A one-job request wins and is never saved, then `agent_model` in this machine's config.yaml, then the preferences file, then `host`. Output names the winner, both stored values and the exemptions; `list` gains an agent block and `list --json` a top-level `agent` key, with `selections` unchanged.
|
||||
- **`agent_model`** in `gstack-config`, checked with the same grammar as the resolver: `host`, a family (`opus`, `fable`, `sonnet`, `haiku`, `astra`, `sol`, `luna`, `terra`) or `<provider>/<model>`. A bad value stops only the agent role, naming the file and the fix; plan-review, implementation and eval-arms keep resolving.
|
||||
- **`capy/skill/gstack/factory.md`**: thirteen practices the Captain crews converged on, each pointing at the rule it summarizes, at most 3,300 bytes. SKILL.md's session start tells every session to read it once.
|
||||
|
||||
### Changed
|
||||
- The Capy session start (`bin/gstack-capy-session`, which updates with the checkout) points `GSTACK_PREFERENCES_FILE` at `preferences.yaml` next to the Drive skill's SKILL.md, and the installer writes it into the launch file. `--check` never writes the skill folder, and the frozen Drive bootstrapper is unchanged.
|
||||
- SKILL.md allows exactly one write in the skill folder: `preferences.yaml`, when the owner asks an agent to remember a preference.
|
||||
- `docs/model-policy.md` gains "Your model preference". `docs/captain.md` links factory.md, says to forward an owner's rule for every Captain, in the same turn, to the thread that keeps the account's shared rules, and says the Captain archives its own child threads by its plan for each, including ones its crew or automations started.
|
||||
|
||||
## [1.91.331.0] - 2026-10-30
|
||||
|
||||
**`/ship` and `/review` go back to the reviewers they used in 1.91.325.0, because the newer reviewers missed real blockers.** In 1.91.326.0 (#3621) each reviewer started reading one prepared packet of the diff instead of reading the diff and the code around it, and the parent read a short findings record. 1.91.330.0 (#3636) let packet reviewers read a little beyond their hunks. A replay of 14 merged PRs (GSTA-167 no-loss round 3) then showed that the old reviewers find 19 of the 30 known blockers, and the packet reviewers, even with the looser reading rule, found only 10 of those 19 (the replay's counting rule favored the old review, and the packet reviewers also found 2 blockers it missed, but the preregistered bar allowed 1 miss and they had 9). So reviews run with gstack 1.91.326.0 through 1.91.330.0 are not as thorough as the review before them, and a PR reviewed on one of those versions may still hide a blocker the old review would have caught.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.331.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.332.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -677,6 +677,7 @@ write_launch_file() {
|
||||
echo "export GSTACK_EPHEMERAL=$(shq "${GSTACK_EPHEMERAL:-1}")"
|
||||
echo "export GSTACK_CAPY=1"
|
||||
echo "export GSTACK_INSTALL_VERDICT=$(shq "$VERDICT")"
|
||||
if [ -n "${GSTACK_PREFERENCES_FILE:-}" ]; then echo "export GSTACK_PREFERENCES_FILE=$(shq "$GSTACK_PREFERENCES_FILE")"; fi
|
||||
} > "$tmp" && chmod 0600 "$tmp" && mv -f "$tmp" "$LAUNCH_FILE" || { rm -f "$tmp"; return 1; }
|
||||
}
|
||||
if [ "$RC" -eq 0 ] && ! write_launch_file; then
|
||||
|
||||
@@ -91,6 +91,18 @@ report_behind() {
|
||||
GSTACK_CAPY_BEHIND_TO="$(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1)" GSTACK_CAPY_BEHIND_FIX="$1"
|
||||
}
|
||||
unset GSTACK_CAPY_BEHIND GSTACK_CAPY_BEHIND_TO GSTACK_CAPY_BEHIND_FIX GSTACK_CAPY_BEHIND_BLOCKED
|
||||
# The owner's preferences.yaml sits beside the Drive skill's SKILL.md and is
|
||||
# read where it lies (never copied), so --check never writes the skill folder
|
||||
# and a newer file is seen by the next command. The installer writes the path
|
||||
# into the launch file; gstack-models resolve --role agent reads it. A wrapper
|
||||
# inside this checkout is no Drive skill, so it names no preferences file.
|
||||
if [ -n "${GSTACK_CAPY_WRAPPER_PATH:-}" ]; then
|
||||
_prefs_dir="$(dirname "$(dirname "$GSTACK_CAPY_WRAPPER_PATH")")"
|
||||
case "$_prefs_dir/" in
|
||||
"$ROOT"/*) ;;
|
||||
*) export GSTACK_PREFERENCES_FILE="$_prefs_dir/preferences.yaml" ;;
|
||||
esac
|
||||
fi
|
||||
RERUN="bash ${GSTACK_CAPY_WRAPPER_PATH:-<skill>/scripts/install.sh}${SKILL:+ --for $SKILL}"
|
||||
if [ "$CHECK" -eq 1 ]; then
|
||||
if behind; then
|
||||
|
||||
+16
-2
@@ -194,6 +194,9 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
|
||||
# Optional pins: model_frontier_claude, model_frontier_openai,
|
||||
# model_smart_claude and model_smart_openai. Leave pins absent for catalog defaults;
|
||||
# empty pins are invalid. gstack-models list prints copyable set/unset commands.
|
||||
# agent_model: the model for gstack agents (lanes, subagents, reviewers): host,
|
||||
# a family (opus, fable, sonnet, haiku, astra, sol, luna, terra) or <provider>/<model>.
|
||||
# Absent = host. A preference, not a policy: gstack-models resolve --role agent.
|
||||
# Invalid writes are rejected and preserve the stored value.
|
||||
# codex_effort: # Codex reasoning effort for outside reviews and /codex:
|
||||
# # minimal | low | medium | high | xhigh | max | ultra.
|
||||
@@ -362,6 +365,7 @@ lookup_default() {
|
||||
plan_review_tier) echo "frontier" ;; # frontier | smart | host — lib/model-policy.ts reads it strictly
|
||||
implementation_tier) echo "smart" ;; # frontier | smart
|
||||
model_frontier_claude|model_frontier_openai|model_smart_claude|model_smart_openai) echo "" ;; # empty = shipped catalog ID (gstack-models list)
|
||||
agent_model) echo "" ;; # empty = host (agents keep their own model); gstack-models resolve --role agent
|
||||
codex_effort) echo "" ;; # empty = each gate's own effort; /codex --effort, then GSTACK_CODEX_EFFORT, win (gstack-codex-probe run-with-timeout --effort)
|
||||
review_effort_tiers) echo "on" ;; # on | off — gstack-review-plan effort tiers (docs/model-policy.md#reasoning-effort)
|
||||
ship_greptile_early) echo "true" ;; # true | false — /ship's early Greptile PR
|
||||
@@ -836,6 +840,16 @@ $_gstack_legacy_root"
|
||||
exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
if [ "$KEY" = "agent_model" ]; then
|
||||
VALUE="${VALUE#"${VALUE%%[![:space:]]*}"}"
|
||||
VALUE="${VALUE%"${VALUE##*[![:space:]]}"}"
|
||||
case "$(printf '%s' "$VALUE" | LC_ALL=C tr 'A-Z' 'a-z')" in
|
||||
host|opus|fable|sonnet|haiku|astra|sol|luna|terra) ;;
|
||||
*) printf '%s' "$VALUE" | LC_ALL=C grep -qE '^[a-z][a-z0-9-]{0,31}/[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$' || {
|
||||
echo "Error: agent_model '$VALUE' not recognized. Use host, a family (opus, fable, sonnet, haiku, astra, sol, luna, terra) or <provider>/<model> such as anthropic/claude-opus-5-5. Existing value left unchanged. fix: gstack-config unset agent_model returns to host (https://github.com/garrytan/gstack/blob/main/docs/model-policy.md#your-model-preference)" >&2
|
||||
exit 1; } ;;
|
||||
esac
|
||||
fi
|
||||
case "$KEY" in
|
||||
plan_review_tier|implementation_tier|model_frontier_claude|model_frontier_openai|model_smart_claude|model_smart_openai)
|
||||
_mp_doc="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#model-policy-config"
|
||||
@@ -1086,7 +1100,7 @@ $_gstack_legacy_root"
|
||||
ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \
|
||||
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
|
||||
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers; do
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do
|
||||
gstack_config_select "$KEY"
|
||||
VALUE="$_gstack_cfg_value"
|
||||
SOURCE="default"
|
||||
@@ -1111,7 +1125,7 @@ $_gstack_legacy_root"
|
||||
ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \
|
||||
ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \
|
||||
plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers; do
|
||||
model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do
|
||||
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
|
||||
done
|
||||
;;
|
||||
|
||||
+1
-1
@@ -211,7 +211,7 @@ did it go on by default.
|
||||
|
||||
- [docs/recommended-harness.md](../docs/recommended-harness.md): set it up, day 0 to the first merged train.
|
||||
- [docs/captain.md](../docs/captain.md): the controller's reference (READY contract, trains, approvals, digest, flaky pass).
|
||||
- [capy/skill/gstack/](skill/gstack/): the Capy skill every thread loads, and its [captain.md](skill/gstack/captain.md) for lanes.
|
||||
- [capy/skill/gstack/](skill/gstack/): the Capy skill every thread loads, its [factory.md](skill/gstack/factory.md) one-page summary of how crews work, and its [captain.md](skill/gstack/captain.md) for lanes.
|
||||
- [docs/why-gstack.md](../docs/why-gstack.md): the longer story behind gstack itself.
|
||||
|
||||
[^1]: Windows are UTC, [2026-10-01, 2026-10-15) before and
|
||||
|
||||
@@ -9,7 +9,7 @@ description: Install and use gstack in Capy and Claude Code. Load at session sta
|
||||
|
||||
Resolve `scripts/install.sh` relative to this SKILL.md, not the working repository. It is a frozen bootstrapper: it prints `gstack-capy-skill: wrapper <n> (<path>)` first, learns the supported minimum from gstack's remote, and hands over to the checkout's own wrapper (`bin/gstack-capy-session`).
|
||||
|
||||
1. At session start run `bash scripts/install.sh --check`, adding `--for <skill>` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN <component>` row per component (`WARN` never fails the run: it names an owner action or a fix you can run), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok <revision> for=<skill>[ (<notes>)]` (exit 0) or `gstack: fail <ids> …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. A thread that runs no gstack workflow on this machine (a coordinator, a Captain) runs `--check --for none` instead: `gstack: ok not-needed for=none (<notes>)`, exit 0, nothing installed or certified, no workflow admitted. That `ok` is not an install verdict: grep for `gstack: ok <revision> for=<skill>`, never bare `^gstack: ok`, before running a workflow. Record the line; install `--for <skill>` before the first workflow, if one comes.
|
||||
1. At session start run `bash scripts/install.sh --check`, adding `--for <skill>` when you know the workflow (`--for autoplan`, `--for review`, `--for qa`). It prints one `PASS|FAIL|SKIP|WARN <component>` row per component (`WARN` never fails the run: it names an owner action or a fix you can run), a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok <revision> for=<skill>[ (<notes>)]` (exit 0) or `gstack: fail <ids> …` (exit 1). Read the exit status and grep `^gstack: ` directly; never pipe the check through `tail`, `head` or `grep` alone. `--check` never moves the checkout. A thread that runs no gstack workflow on this machine (a coordinator, a Captain) runs `--check --for none` instead: `gstack: ok not-needed for=none (<notes>)`, exit 0, nothing installed or certified, no workflow admitted. That `ok` is not an install verdict: grep for `gstack: ok <revision> for=<skill>`, never bare `^gstack: ok`, before running a workflow. Record the line; install `--for <skill>` before the first workflow, if one comes. After `gstack: ok`, read `capy/skill/gstack/factory.md` in the checkout (`${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}`) once per session, if present: how crews work, with pointers to the rules.
|
||||
2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or an ok trailer carries `floor=<v> (<file>)` or `behind=<n> (latest: <v>)` with a `fix: <command>` note, run the same command without `--check` (900-second timeout), or the `fix:` command when it names another. `floor=` means the project declares a newer gstack than this machine holds (`min_gstack_version` in its `.gstack/ship-policy.json`; the `WARN floor` row): run the fix before the first workflow, every time. `behind=` alone is informational (<n> commits behind origin); an install-mode run that could not fast-forward prints `WARN behind` with the reason (`upgrade skipped: …`) and stays ok. Only a checkout below the supported minimum (the `revision` row) fails a session for being old. Report the trailer verbatim, `behind=` and `floor=` included. For `pins` it installs the project's pinned Bun when the pin names a verified version, or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. Any clean, unpinned checkout behind origin is fast-forwarded in install mode (below the `GSTACK_MIN_REVISION` floor by the bootstrapper, `gstack-capy-skill: upgraded <old> -> <new>`; above it by the checkout, `SELF_UPGRADE: done <old> -> <new>`), reinstalled and rechecked in one locked step. A pinned (`--revision`, detached), dirty or diverged checkout, or one another live session holds a lease on (`UPGRADE_BUSY`), is never moved: the trailer names the cause and the exact commands (above the floor it stays ok with `WARN behind` after `SELF_UPGRADE: skipped (<reason>)`, carrying `UPGRADE_BUSY` or `UPGRADE_REFUSED`). A lease whose pid was reused after a wake is reclaimed on its own. `GSTACK_CAPY_AUTO_UPGRADE=0` disables every fast-forward, `GSTACK_CAPY_SELF_UPGRADE=off` only the above-floor one.
|
||||
3. If the trailer is `gstack: fail not-installed …` and this session runs no gstack workflow, record that line and continue; nothing is installed. Install right before the first workflow with `bash scripts/install.sh --for <skill>` (about 30 s cold: clone, a SHA-256-verified Bun when none installed fits, both host profiles without Chromium, and a `~/.local/bin/bun` shim so fresh shells get the recorded Bun).
|
||||
4. Any other `FAIL` row carries a `fix:` command and a reason code with a troubleshooting anchor; run the fix, then `--check` again. `SKIP codex-cli (optional; …)`, `SKIP browser (lazy; …)`, `SKIP pins (planning-only; …)`, `SKIP pins (helper runtime; …)` and `SKIP floor (…)` are not failures. `WARN pins … (node pin only; bun pins pass)` is not one either: the Node on PATH misses the project's pin while Bun is right; run its fix only when this machine will run the project's tests.
|
||||
@@ -18,7 +18,7 @@ Resolve `scripts/install.sh` relative to this SKILL.md, not the working reposito
|
||||
|
||||
**Subagents.** A subagent's first report to its parent carries the `gstack:` trailer line from its own machine's `--check`, verbatim, after its identifiers. A parent treats a missing line as `gstack: fail not-installed`. A reviewer subagent that only reads and reports (an /autoplan voice, a second opinion) runs `--for review-voice` instead: seconds when warm, no `./setup`, no host profiles, Bun at gstack's own minimum, and a trailer that certifies only the read-only helpers (`gstack-pregate sealed`, `gstack-plan-reality`, `gstack-artifact`, the `gstack-autoplan` reads); every workflow refuses it with a `--for <skill>` fix. Never brief a reviewer that no install is needed. A reviewer later asked to run a workflow runs the same installer `--for <skill>`: it upgrades the checkout in place (pins, `./setup`, host profiles; no second clone). A machine without this skill (a brief you write for a subagent) runs the checkout's own bootstrapper from its project directory: `test -f "$HOME/.local/share/gstack/setup" || git clone --quiet --single-branch --depth 1 https://github.com/garrytan/gstack.git "$HOME/.local/share/gstack" && GSTACK_CAPY_ROOT="$HOME/.local/share/gstack" bash "$HOME/.local/share/gstack/capy/skill/gstack/scripts/install.sh" --for <skill>`.
|
||||
|
||||
`gstack-capy-skill: owner action — Drive copy is wrapper <n>, minimum <m>; copy <root>/capy/skill/gstack/ (CAPY_WRAPPER_STALE)` means this skill folder is older than the checkout wants. The run still works; tell the owner, who copies that directory over the skill folder. Never write the skill folder yourself.
|
||||
`gstack-capy-skill: owner action — Drive copy is wrapper <n>, minimum <m>; copy <root>/capy/skill/gstack/ (CAPY_WRAPPER_STALE)` means this skill folder is older than the checkout wants. The run still works; tell the owner, who copies that directory over the skill folder. Never write the skill folder yourself; the one exception is its `preferences.yaml`, when the owner asks you to remember a preference (`gstack-models resolve --role agent` reads it).
|
||||
|
||||
## Launch every gstack command with the GSTACK_LAUNCH line
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# How we work: the gstack software factory on Capy
|
||||
|
||||
What seven Captain crews on gstack and Capy converged on (2026-10-29). Your crew's CREW.md and recorded approvals decide who may merge, update a base, spend or touch production; urgency changes priority, never permissions or gates. With no Captain, ask the person and never merge without their word. Story and numbers: capy/README.md; evidence and examples: your organization Drive's software-factory/README.md, when one exists.
|
||||
|
||||
1. **Plan, then a one-word yes.** New functionality starts with /autoplan sized to the work, and the build waits for "rec" or "do it". [SKILL.md: Plan, approve, ship]
|
||||
2. **Someone else merges.** Run /ship to a receipt on your final pushed head, send one READY that quotes gstack's verify output word for word, and stop. The merge owner merges on green, and versions are assigned at merge. [docs/captain.md: The READY contract, Merge trains]
|
||||
3. **Block only on the floor.** A finding blocks if it would fail a test, break production, or lose data or secrets; the rest goes in the PR body or the next wave. [docs/review-policy.md]
|
||||
4. **Fix waves.** When main goes red, hold /ship while one owner finds the root cause; bring main in once, as your crew's rules allow. Fold deferred findings and test follow-ups into one wave PR. [docs/triage.md]
|
||||
5. **Severe first, same gates.** A severe user-facing bug jumps the queue; the plan can be short, but the receipt, checks and merge owner stay.
|
||||
6. **Trade findings, not small talk.** A finding that belongs to another crew goes to its Captain, through yours, with the evidence.
|
||||
7. **Measure before you act.** Time it, count it, run the real system, on the merged tree you ship; a real run outranks a reviewed plan. Each number names its run, commit or uncapped query; say what you did not verify.
|
||||
8. **Evals decide, and losses get reported.** Preregister bars, cap and stop rule before the first paid call [/eval-plan]. Cheap proof first: a keyless fake, a small smoke, then the paid run. Use controls: no-skill and placebo arms, decoys verified clean. Report use and pass separately and quote spend from the meter.
|
||||
9. **Flakes: one rerun, a ledger, a daily fix.** Only the rerun the policy allows, never a harness or infra red; a second red is real, and no test is loosened. [AGENTS.md: Validation discipline; docs/captain.md: The daily flaky pass]
|
||||
10. **Quiet.** Message on a decision, a blocker, a READY or a finished phase. A broadcast that needs no reply goes in the Captain's notes, not to every lane; never reply just to acknowledge.
|
||||
11. **Ask once, plainly.** One open ask per decision, on an issue: recommendation first, one line per choice, everyday words, links inline. Never re-send it or re-ask a standing rule. Settle what you can check yourself.
|
||||
12. **Fresh threads, closed out.** One thread per unit of work (crew threads ask their Captain for new ones); past the context threshold, hand off. Its Captain archives each thread it, its crew or its automations start, by its plan for it. Size reviews to the diff; read the token counter, don't estimate. [docs/ship-budget.md]
|
||||
13. **The owner's model preference.** `gstack-models resolve --role agent` picks agent models, except cross-family voices and eval subjects. [docs/model-policy.md: Your model preference]
|
||||
+10
-3
@@ -10,7 +10,9 @@ to `/crew`); the lane-side rules for a thread working under a Captain are in
|
||||
[capy-install.md](capy-install.md#working-under-a-captain). Every file named below has a
|
||||
copy-paste template under [`captain/templates/`](../captain/templates/). For the story of how
|
||||
one owner's crews run this day to day, with measured numbers, read
|
||||
[capy/README.md](../capy/README.md).
|
||||
[capy/README.md](../capy/README.md). Every Capy session also reads
|
||||
[`capy/skill/gstack/factory.md`](../capy/skill/gstack/factory.md), a one-page summary of
|
||||
these practices with pointers to the rules.
|
||||
|
||||
## The model in one paragraph
|
||||
|
||||
@@ -32,7 +34,10 @@ by a daily pass; nothing is retried into green.
|
||||
- **The controller** (the Captain) runs the crew: starts lanes on disjoint files, keeps the
|
||||
rules file, the digest and the notes, validates READYs, builds and merges trains, calls
|
||||
slots, relays asks to the owner in the three shapes, runs the flaky pass, and decides
|
||||
everything that needs no access, credentials, spend over cap or a cited approval.
|
||||
everything that needs no access, credentials, spend over cap or a cited approval. It
|
||||
archives its own child threads, including ones its crew or its automations started
|
||||
outside the crew, when its plan for each thread says that thread's work is done; it
|
||||
knows each thread's next step, so nobody else archives them.
|
||||
- **A lane** (a crew thread) owns named files, builds on its own branch and machine,
|
||||
runs /ship, sends one READY block, stamps on call (merging the base into its pin only
|
||||
as part of that stamp commit, or when the controller names a real-code conflict),
|
||||
@@ -361,7 +366,9 @@ only the run the lane named.
|
||||
Every report ends with one Lesson. The controller reads lessons before chat, and each
|
||||
one becomes a rule in CREW.md, a line in AGENTS.md, or a tool fix filed as an issue;
|
||||
nothing stays a lesson for long. Rules carry the thread that produced them, so a new lane
|
||||
can read the incident behind the rule.
|
||||
can read the incident behind the rule. When the owner states a rule meant for every
|
||||
Captain, forward it in the same turn to the thread that keeps the account's shared rules,
|
||||
so every crew gets it.
|
||||
|
||||
## Setting it up on Capy
|
||||
|
||||
|
||||
@@ -423,6 +423,11 @@ GSTACK_LAUNCH_FILE: <verdict file without .json>.env
|
||||
|
||||
`GSTACK_LAUNCH_FILE` names the same assignments as a sourceable script (mode
|
||||
0600, beside the verdict under the state root; the PATH prepend is idempotent).
|
||||
When the session starts from the Drive skill (a bootstrapper outside the
|
||||
checkout), the file also exports `GSTACK_PREFERENCES_FILE`, the path of
|
||||
`preferences.yaml` beside that skill's SKILL.md, which the `GSTACK_LAUNCH` line
|
||||
leaves out; `gstack-models resolve --role agent` reads it
|
||||
([your model preference](model-policy.md#your-model-preference)).
|
||||
The file is part of the ok verdict: a passing run that cannot write it exits 1
|
||||
with `gstack: fail launch-file — could not write the launch file <path>; fix:
|
||||
make <dir> writable, or set GSTACK_STATE_ROOT to a writable directory
|
||||
|
||||
@@ -84,6 +84,7 @@ coverage, not a completed review.
|
||||
| `model_frontier_openai` | Catalog's OpenAI frontier model | Explicit OpenAI model identifier |
|
||||
| `model_smart_claude` | Catalog's Anthropic smart model | Explicit Anthropic model identifier |
|
||||
| `model_smart_openai` | Catalog's OpenAI smart model | Explicit OpenAI model identifier |
|
||||
| `agent_model` | unset (`host`) | `host`, a family or `<provider>/<model>`; see [your model preference](#your-model-preference) |
|
||||
|
||||
For plan reviews, a model named for that invocation wins over
|
||||
`GSTACK_CLAUDE_MODEL` or `GSTACK_CODEX_MODEL`. That environment override wins over
|
||||
@@ -120,6 +121,48 @@ JSON output list these limits. Use an explicit compatible model for those cases,
|
||||
or `host` mode for plan reviews. Implementation recommendations require an
|
||||
explicit implementation model rather than delegating to a hidden native choice.
|
||||
|
||||
## Your model preference
|
||||
|
||||
gstack takes no position for or against any model. If you prefer one, gstack
|
||||
remembers it and uses it whenever it picks a model for a working agent: a lane,
|
||||
a subagent or a reviewer. When it is unset, every agent keeps its own model.
|
||||
|
||||
```bash
|
||||
"$GSTACK_ROOT/bin/gstack-config" set agent_model opus
|
||||
"$GSTACK_ROOT/bin/gstack-models" resolve --role agent
|
||||
```
|
||||
|
||||
| Value | Meaning |
|
||||
| --- | --- |
|
||||
| `host` (the default) | keep the running agent's own model and leave the model field out |
|
||||
| a family: `opus`, `fable`, `sonnet`, `haiku`, `astra`, `sol`, `luna`, `terra` | today's model in that family (`opus` is `anthropic/claude-opus-5-5`); it moves when gstack's family table moves |
|
||||
| `<provider>/<model>` | that exact model, for example `anthropic/claude-opus-5-5` |
|
||||
|
||||
The first match wins: a request for one job (`resolve --role agent --request sol`,
|
||||
never saved), then `agent_model` in this machine's config.yaml, then
|
||||
`agent_model` in the file `GSTACK_PREFERENCES_FILE` names, then `host`. The
|
||||
output names the winner and shows both stored values; `--json` prints the
|
||||
provider, model, provider-qualified `id` and source.
|
||||
|
||||
Two cases ignore the preference on purpose: a cross-family outside voice (the
|
||||
`plan-review` role picks the other provider) and a model under test in an eval
|
||||
(`eval-arms` and `/eval-plan` arms).
|
||||
|
||||
A value gstack can't use stops only the agent role, naming the file and the fix.
|
||||
`plan-review`, `implementation` and `eval-arms` keep resolving, and
|
||||
`gstack-models list` shows the problem in its agent block.
|
||||
|
||||
**On Capy.** Each cloud machine starts fresh, so config.yaml does not carry
|
||||
over. Keep the preference in `preferences.yaml` next to the gstack skill's
|
||||
SKILL.md in your Drive: one `agent_model: <value>` line, with `#` comments
|
||||
allowed (any other key is ignored with a warning). The session start puts the
|
||||
file's path in the launch file as `GSTACK_PREFERENCES_FILE` and gstack reads it
|
||||
where it is: nothing is copied, and `--check` never writes the skill folder. Agents write that
|
||||
file only when you ask them to remember a preference. To change or clear it for
|
||||
good, edit or delete that line; `gstack-config unset agent_model` clears this
|
||||
machine only. Copying a newer `capy/skill/gstack/` over the skill folder leaves
|
||||
`preferences.yaml` alone, because the shipped folder has no such file.
|
||||
|
||||
## Reasoning effort
|
||||
|
||||
Codex calls carry a `model_reasoning_effort`. It follows the same shape as the
|
||||
@@ -213,6 +256,9 @@ an environment pin. See [selection recovery](troubleshooting.md#model-policy-sel
|
||||
`gstack-models` with no arguments is `list`. Both `list` and `resolve` accept
|
||||
`--json`. Plan-review resolution requires `--provider anthropic|openai`;
|
||||
implementation resolution can omit the provider to return both recommendations.
|
||||
Agent resolution (`--role agent`) takes no `--provider`; its JSON carries an
|
||||
`agent` object instead of `selections`, and `list --json` adds the same
|
||||
top-level `agent` key.
|
||||
|
||||
```bash
|
||||
"$GSTACK_ROOT/bin/gstack-models" resolve --role plan-review --provider anthropic --json
|
||||
|
||||
@@ -37,7 +37,7 @@ no paid call: the Codex rows report the cached model check and its age, and
|
||||
|
||||
## Model policy
|
||||
|
||||
The [model-policy guide](model-policy.md) explains the two tiers, all six
|
||||
The [model-policy guide](model-policy.md) explains the two tiers, all seven
|
||||
settings, precedence and the read-only inspection command. A resolved selection
|
||||
is not proof that the requested provider or model can run.
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { readConfigKeyWithRoot, type StateRootEnv } from './state-root';
|
||||
|
||||
/**
|
||||
* The agent role: the model a user wants gstack's working agents (lanes,
|
||||
* subagents, reviewers) to run on. It is a preference the user sets, never a
|
||||
* policy gstack ships, and it is read apart from the strict model policy so a
|
||||
* bad value here never stops plan-review, implementation or eval-arms picks.
|
||||
* Precedence: a one-job request, then agent_model in config.yaml (this
|
||||
* machine), then agent_model in the file GSTACK_PREFERENCES_FILE names (the
|
||||
* Capy skill folder's preferences.yaml, kept across machines), then host.
|
||||
*/
|
||||
|
||||
export const AGENT_MODEL_KEY = 'agent_model' as const;
|
||||
export const PREFERENCES_FILE_MAX_BYTES = 4096;
|
||||
const DOCS = 'https://github.com/garrytan/gstack/blob/main/docs/model-policy.md#your-model-preference';
|
||||
|
||||
/** Family names a preference may use. When gstack's tier catalog lists a family, its entry here is the same model (test/agent-model.test.ts). */
|
||||
export const MODEL_FAMILIES = Object.freeze([
|
||||
{ family: 'opus', provider: 'anthropic', model: 'claude-opus-5-5' },
|
||||
{ family: 'fable', provider: 'anthropic', model: 'claude-fable-5-1' },
|
||||
{ family: 'sonnet', provider: 'anthropic', model: 'claude-sonnet-5-5' },
|
||||
{ family: 'haiku', provider: 'anthropic', model: 'claude-haiku-5-5' },
|
||||
{ family: 'astra', provider: 'openai', model: 'gpt-6-astra' },
|
||||
{ family: 'sol', provider: 'openai', model: 'gpt-6.1-sol' },
|
||||
{ family: 'luna', provider: 'openai', model: 'gpt-6-luna' },
|
||||
{ family: 'terra', provider: 'openai', model: 'gpt-6.1-terra' },
|
||||
].map(f => Object.freeze(f)));
|
||||
|
||||
export const AGENT_MODEL_EXEMPTIONS = Object.freeze([
|
||||
'a cross-family outside voice (gstack-models resolve --role plan-review)',
|
||||
'a model under test in an eval (gstack-models resolve --role eval-arms, /eval-plan arms)',
|
||||
]);
|
||||
|
||||
const PROVIDER_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
|
||||
|
||||
export type AgentModelSourceKind = 'request' | 'config' | 'preferences' | 'default';
|
||||
|
||||
export interface AgentModelSelection {
|
||||
role: 'agent';
|
||||
status: 'selected' | 'host';
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
/** Provider-qualified id, the form a Capy model field takes; null in host mode (omit the model field). */
|
||||
id: string | null;
|
||||
family: string | null;
|
||||
value: string;
|
||||
source: { kind: AgentModelSourceKind; path: string | null };
|
||||
sources: { config: string | null; configPath: string | null; preferences: string | null; preferencesPath: string | null };
|
||||
warnings: string[];
|
||||
exemptions: readonly string[];
|
||||
}
|
||||
|
||||
export class AgentModelError extends Error {
|
||||
constructor(readonly problem: string, readonly cause: string, readonly repair: string[]) {
|
||||
super(problem);
|
||||
}
|
||||
toJSON() {
|
||||
return { reason: 'invalid_agent_model', problem: this.problem, cause: this.cause, key: AGENT_MODEL_KEY, repair: this.repair, docs: DOCS };
|
||||
}
|
||||
}
|
||||
|
||||
function printable(value: string): string {
|
||||
return value.replace(/[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ' ');
|
||||
}
|
||||
|
||||
function sanitize(value: string): string {
|
||||
return printable(value).slice(0, 200);
|
||||
}
|
||||
|
||||
type Parsed = { kind: 'host' } | { kind: 'model'; provider: string; model: string; family: string | null };
|
||||
|
||||
/** host | a family name | <provider>/<model>. Syntax only; bin/gstack-config holds the same grammar. */
|
||||
export function parseAgentModel(raw: string): Parsed | null {
|
||||
const value = raw.trim();
|
||||
if (value.toLowerCase() === 'host') return { kind: 'host' };
|
||||
const family = MODEL_FAMILIES.find(f => f.family === value.toLowerCase());
|
||||
if (family) return { kind: 'model', provider: family.provider, model: family.model, family: family.family };
|
||||
const slash = value.indexOf('/');
|
||||
if (slash < 0) return null;
|
||||
const provider = value.slice(0, slash);
|
||||
const model = value.slice(slash + 1);
|
||||
if (!PROVIDER_PATTERN.test(provider) || !MODEL_PATTERN.test(model)) return null;
|
||||
return { kind: 'model', provider, model, family: null };
|
||||
}
|
||||
|
||||
function invalid(value: string, where: string, repair: string[]): AgentModelError {
|
||||
return new AgentModelError(
|
||||
`agent_model '${sanitize(value)}' in ${where} is not a model gstack can use`,
|
||||
`use host, a family (${MODEL_FAMILIES.map(f => f.family).join(', ')}) or a provider-qualified id such as anthropic/claude-opus-5-5`,
|
||||
repair,
|
||||
);
|
||||
}
|
||||
|
||||
/** Read agent_model from a preferences file: `key: value` lines, # comments, at most 4 KB. Unknown keys warn; a duplicate agent_model is an error. */
|
||||
export function readPreferencesFile(file: string): { value: string | null; warnings: string[] } {
|
||||
let text: string;
|
||||
try {
|
||||
const stat = fs.statSync(file);
|
||||
if (!stat.isFile()) return { value: null, warnings: [`${file} is not a file; ignored`] };
|
||||
const bytes = stat.size > PREFERENCES_FILE_MAX_BYTES ? null : fs.readFileSync(file);
|
||||
if (!bytes || bytes.length > PREFERENCES_FILE_MAX_BYTES) return { value: null, warnings: [`${file} is over ${PREFERENCES_FILE_MAX_BYTES} bytes; ignored`] };
|
||||
text = bytes.toString('utf-8').replace(/^\uFEFF/, '');
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return { value: null, warnings: code === 'ENOENT' ? [] : [`${file} is unreadable (${code ?? 'unknown error'}); ignored`] };
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
let value: string | null = null;
|
||||
text.split(/\r?\n/).forEach((line, i) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '' || trimmed.startsWith('#')) return;
|
||||
const m = /^([a-z][a-z0-9_]*):(.*)$/.exec(line);
|
||||
if (!m) { warnings.push(`${file}:${i + 1} is not a 'key: value' line; ignored`); return; }
|
||||
if (m[1] !== AGENT_MODEL_KEY) { warnings.push(`${file}:${i + 1} sets '${sanitize(m[1])}', which gstack does not read from preferences; ignored`); return; }
|
||||
if (value !== null) throw new AgentModelError(`${file} sets agent_model twice`, 'keep one agent_model line', [`edit ${file}`]);
|
||||
value = m[2].replace(/\s#.*$/, '').trim().replace(/^(["'])(.*)\1$/, '$2');
|
||||
});
|
||||
return { value: value === '' ? null : value, warnings };
|
||||
}
|
||||
|
||||
export function resolveAgentModel(opts: { env?: StateRootEnv; platform?: NodeJS.Platform; request?: string } = {}): AgentModelSelection {
|
||||
const env = opts.env ?? process.env;
|
||||
const reading = readConfigKeyWithRoot('agent_model', env, opts.platform ?? process.platform);
|
||||
const configPath = reading.root ? path.join(reading.root, 'config.yaml') : null;
|
||||
const preferencesPath = env.GSTACK_PREFERENCES_FILE?.trim() || null;
|
||||
let prefs: { value: string | null; warnings: string[] } = { value: null, warnings: [] };
|
||||
try {
|
||||
if (preferencesPath) prefs = readPreferencesFile(preferencesPath);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AgentModelError) || (opts.request === undefined && reading.value === null)) throw error;
|
||||
prefs = { value: null, warnings: [`${error.problem}; ignored because a higher-priority source is set`] };
|
||||
}
|
||||
const candidates: Array<{ kind: AgentModelSourceKind; value: string | null; path: string | null; repair: string[] }> = [
|
||||
{ kind: 'request', value: opts.request ?? null, path: null, repair: ['pass --request host, a family or <provider>/<model>'] },
|
||||
{ kind: 'config', value: reading.value, path: configPath, repair: ['gstack-config set agent_model <host|family|provider/model>', 'gstack-config unset agent_model'] },
|
||||
{ kind: 'preferences', value: prefs.value, path: preferencesPath, repair: [`edit agent_model in ${preferencesPath}`] },
|
||||
];
|
||||
const winner = candidates.find(c => c.value !== null);
|
||||
const base = {
|
||||
role: 'agent' as const,
|
||||
sources: { config: reading.value, configPath, preferences: prefs.value, preferencesPath },
|
||||
warnings: prefs.warnings,
|
||||
exemptions: AGENT_MODEL_EXEMPTIONS,
|
||||
};
|
||||
if (!winner) {
|
||||
return { ...base, status: 'host', provider: null, model: null, id: null, family: null, value: 'host', source: { kind: 'default', path: null } };
|
||||
}
|
||||
const parsed = parseAgentModel(winner.value!);
|
||||
if (!parsed) throw invalid(winner.value!, winner.kind === 'request' ? 'this request' : winner.path ?? winner.kind, winner.repair);
|
||||
const source = { kind: winner.kind, path: winner.path };
|
||||
if (parsed.kind === 'host') return { ...base, status: 'host', provider: null, model: null, id: null, family: null, value: 'host', source };
|
||||
return { ...base, status: 'selected', provider: parsed.provider, model: parsed.model, id: `${parsed.provider}/${parsed.model}`, family: parsed.family, value: winner.value!.trim(), source };
|
||||
}
|
||||
|
||||
export function renderAgentModel(s: AgentModelSelection): string[] {
|
||||
const from = s.source.kind === 'default' ? 'no preference set'
|
||||
: s.source.kind === 'request' ? 'requested for this job, not saved'
|
||||
: `from agent_model in ${s.source.path}, ${s.source.kind === 'config' ? 'this machine' : 'the preferences file'}`;
|
||||
const lines = [s.status === 'host'
|
||||
? `agent: host - keep the running agent's own model and omit the model field (${from})`
|
||||
: `agent: ${s.id}${s.family ? ` (${s.family})` : ''} - ${from}`].map(printable);
|
||||
lines.push(printable(` config.yaml agent_model: ${s.sources.config ?? 'unset'}; preferences agent_model: ${s.sources.preferences ?? 'unset'}${s.sources.preferencesPath ? ` (${s.sources.preferencesPath})` : ' (GSTACK_PREFERENCES_FILE unset)'}`));
|
||||
lines.push(` exempt: ${s.exemptions.join('; ')}`);
|
||||
lines.push(' change it on this machine: gstack-config set agent_model <host|family|provider/model>; keep it across Capy machines: agent_model in the skill folder\'s preferences.yaml');
|
||||
for (const w of s.warnings) lines.push(` warning: ${sanitize(w)}`);
|
||||
return lines;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export const CLAUDE_CODE_RUNTIME_FILES = [
|
||||
'bin/gstack-claude-code', 'bin/gstack-models', 'bin/gstack-config', 'bin/gstack-state-root.sh',
|
||||
'lib/claude-code.ts', 'lib/claude-code-windows-job.ts', 'lib/claude-bin.ts',
|
||||
'lib/outside-review-result.ts', 'lib/gate-outcomes.ts', 'lib/outside-consent.ts', 'lib/state-root.ts',
|
||||
'lib/model-catalog.ts', 'lib/model-policy.ts', 'lib/model-policy-cli.ts', 'lib/model-policy-notice.ts',
|
||||
'lib/model-catalog.ts', 'lib/model-policy.ts', 'lib/model-policy-cli.ts', 'lib/model-policy-notice.ts', 'lib/agent-model.ts',
|
||||
];
|
||||
|
||||
export interface RenameOptions {
|
||||
|
||||
+57
-5
@@ -15,6 +15,7 @@ import {
|
||||
type ModelSelection,
|
||||
} from './model-policy';
|
||||
import { readConfigKey, type StateRootEnv } from './state-root';
|
||||
import { AgentModelError, renderAgentModel, resolveAgentModel, type AgentModelSelection } from './agent-model';
|
||||
|
||||
export interface ModelsCliResult {
|
||||
code: 0 | 1 | 2;
|
||||
@@ -33,6 +34,7 @@ export const MODELS_HELP = `Usage:
|
||||
gstack-models resolve --role plan-review --provider anthropic|openai [--json]
|
||||
gstack-models resolve --role implementation [--provider anthropic|openai] [--json]
|
||||
gstack-models resolve --role eval-arms [--provider anthropic|openai] [--json]
|
||||
gstack-models resolve --role agent [--request <model>] [--json]
|
||||
gstack-models --help
|
||||
|
||||
Shows which model each gstack role uses and why. It reads config.yaml, the
|
||||
@@ -57,6 +59,15 @@ Roles:
|
||||
eval-arms The arms /eval-plan compares: the implementation-tier model of
|
||||
each provider under the same precedence (plan E1). Pricing per
|
||||
arm comes from lib/pricing.ts via gstack-eval-plan arms.
|
||||
agent The model you want gstack's working agents (lanes, subagents,
|
||||
reviewers) to run on: your preference, not a gstack policy.
|
||||
Precedence: --request (one job, never saved), then agent_model
|
||||
in config.yaml (this machine), then agent_model in the file
|
||||
GSTACK_PREFERENCES_FILE names (on Capy, preferences.yaml in
|
||||
the gstack skill folder), then host (keep the agent's own
|
||||
model). Values: host, a family (opus, fable, sonnet, haiku,
|
||||
astra, sol, luna, terra) or <provider>/<model>. A cross-family
|
||||
outside voice and a model under test in an eval ignore it.
|
||||
|
||||
Settings (gstack-config set|unset <key>):
|
||||
plan_review_tier frontier | smart | host (default frontier)
|
||||
@@ -180,7 +191,7 @@ function renderSelections(role: ModelRole, config: ModelPolicyConfig, selections
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderList(config: ModelPolicyConfig, selections: ModelSelection[], env: StateRootEnv): string {
|
||||
function renderList(config: ModelPolicyConfig, selections: ModelSelection[], env: StateRootEnv, platform: NodeJS.Platform): string {
|
||||
const effort = codexEffortSetting(env);
|
||||
const commands = modelPolicyCommands();
|
||||
const configCommand = `'${CONFIG_BIN.replaceAll('\\', '/').replaceAll("'", "'\\''")}'`;
|
||||
@@ -193,6 +204,8 @@ function renderList(config: ModelPolicyConfig, selections: ModelSelection[], env
|
||||
'',
|
||||
`codex effort: ${effort.value ?? 'each gate\'s default'} (${effort.source === 'env' ? 'GSTACK_CODEX_EFFORT' : effort.source === 'config' ? `codex_effort in ${config.path}` : 'default; set codex_effort'})${effort.valid ? '' : ` - invalid; Codex calls stop before sending. Use ${CODEX_EFFORT_LEVELS.join(', ')}`}; /spec keeps medium`,
|
||||
'',
|
||||
...agentLines(env, platform),
|
||||
'',
|
||||
`catalog v${MODEL_CATALOG_VERSION} (sha256 ${modelCatalogSha256().slice(0, 12)})`,
|
||||
];
|
||||
for (const tier of MODEL_TIERS) {
|
||||
@@ -233,6 +246,37 @@ function renderResolve(role: ModelRole, config: ModelPolicyConfig, selections: M
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function agentOrError(env: StateRootEnv, platform?: NodeJS.Platform, request?: string): AgentModelSelection | { error: ReturnType<AgentModelError['toJSON']> } {
|
||||
try {
|
||||
return resolveAgentModel({ env, platform, request });
|
||||
} catch (error) {
|
||||
if (!(error instanceof AgentModelError)) throw error;
|
||||
return { error: error.toJSON() };
|
||||
}
|
||||
}
|
||||
|
||||
function renderAgentError(error: ReturnType<AgentModelError['toJSON']>): string[] {
|
||||
return [`agent: ${error.problem}`, ` cause: ${error.cause}`, ...error.repair.map(step => ` fix: ${step}`), ` docs: ${error.docs}`];
|
||||
}
|
||||
|
||||
/** The agent block of `list`: a bad agent_model is reported here and never fails the other roles. */
|
||||
function agentLines(env: StateRootEnv, platform: NodeJS.Platform): string[] {
|
||||
const agent = agentOrError(env, platform);
|
||||
return 'error' in agent ? renderAgentError(agent.error) : renderAgentModel(agent);
|
||||
}
|
||||
|
||||
function agentResult(env: StateRootEnv, platform: NodeJS.Platform, request: string | undefined, json: boolean): ModelsCliResult {
|
||||
const agent = agentOrError(env, platform, request);
|
||||
if ('error' in agent) {
|
||||
return json
|
||||
? { code: 1, stdout: `${JSON.stringify({ schemaVersion: 1, command: 'resolve', ok: false, offline: true, note: OFFLINE_NOTE, agent: null, errors: [agent.error] }, null, 2)}\n`, stderr: '' }
|
||||
: { code: 1, stdout: '', stderr: renderAgentError(agent.error).map(l => `gstack-models: ${l.trimStart()}`).join('\n') + '\n' };
|
||||
}
|
||||
return json
|
||||
? { code: 0, stdout: `${JSON.stringify({ schemaVersion: 1, command: 'resolve', ok: true, offline: true, note: OFFLINE_NOTE, agent, errors: [] }, null, 2)}\n`, stderr: '' }
|
||||
: { code: 0, stdout: [...renderAgentModel(agent), `(${OFFLINE_NOTE})`].join('\n') + '\n', stderr: '' };
|
||||
}
|
||||
|
||||
export function modelsMain(args: string[], env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform, cwd: string = process.cwd()): ModelsCliResult {
|
||||
const json = args.includes('--json');
|
||||
if (args.includes('--help') || args.includes('-h')) return { code: 0, stdout: MODELS_HELP, stderr: '' };
|
||||
@@ -244,7 +288,7 @@ export function modelsMain(args: string[], env: StateRootEnv = process.env, plat
|
||||
const arg = rest.shift()!;
|
||||
if (arg === '--json') continue;
|
||||
const [name, inline] = arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, undefined];
|
||||
if (command === 'list' || (name !== '--role' && name !== '--provider')) return usage(`unknown argument '${arg}' for ${command}`, json);
|
||||
if (command === 'list' || (name !== '--role' && name !== '--provider' && name !== '--request')) return usage(`unknown argument '${arg}' for ${command}`, json);
|
||||
if (options[name] !== undefined) return usage(`${name} given twice`, json);
|
||||
const value = inline ?? rest.shift();
|
||||
if (value === undefined || value === '' || value.startsWith('--')) return usage(`${name} needs a value`, json);
|
||||
@@ -254,7 +298,12 @@ export function modelsMain(args: string[], env: StateRootEnv = process.env, plat
|
||||
const providerArg = options['--provider'];
|
||||
const provider = parseProvider(providerArg);
|
||||
if (command === 'resolve') {
|
||||
if (role !== 'plan-review' && role !== 'implementation' && role !== 'eval-arms') return usage(role === undefined ? 'resolve needs --role plan-review|implementation|eval-arms' : `unknown role '${role}': use plan-review, implementation or eval-arms`, json);
|
||||
if (role !== 'plan-review' && role !== 'implementation' && role !== 'eval-arms' && role !== 'agent') return usage(role === undefined ? 'resolve needs --role plan-review|implementation|eval-arms|agent' : `unknown role '${role}': use plan-review, implementation, eval-arms or agent`, json);
|
||||
if (role === 'agent') {
|
||||
if (providerArg !== undefined) return usage('resolve --role agent takes no --provider; the preference names its provider', json);
|
||||
return agentResult(env, platform, options['--request'], json);
|
||||
}
|
||||
if (options['--request'] !== undefined) return usage('--request applies only to --role agent', json);
|
||||
if (providerArg !== undefined && !provider) return usage(`unknown provider '${providerArg}': use anthropic or openai`, json);
|
||||
if (role === 'plan-review' && !provider) return usage('resolve --role plan-review needs --provider anthropic|openai (the opposing provider for your host)', json);
|
||||
}
|
||||
@@ -283,7 +332,10 @@ export function modelsMain(args: string[], env: StateRootEnv = process.env, plat
|
||||
// eval-arms (plan E1): the implementation-tier resolution of each provider, one arm each.
|
||||
if (role === 'eval-arms') for (const s of selections) (s as { role: string }).role = 'eval-arms';
|
||||
const code = errors.length ? 1 : 0;
|
||||
if (json) return { code, stdout: `${JSON.stringify(document(command, config, selections, errors, env), null, 2)}\n`, stderr: '' };
|
||||
const stdout = command === 'list' ? renderList(config, selections, env) : role === 'eval-arms' ? renderEvalArms(config, selections) : renderResolve(role as ModelRole, config, selections);
|
||||
if (json) {
|
||||
const doc = document(command, config, selections, errors, env);
|
||||
return { code, stdout: `${JSON.stringify(command === 'list' ? { ...doc, agent: agentOrError(env, platform) } : doc, null, 2)}\n`, stderr: '' };
|
||||
}
|
||||
const stdout = command === 'list' ? renderList(config, selections, env, platform) : role === 'eval-arms' ? renderEvalArms(config, selections) : renderResolve(role as ModelRole, config, selections);
|
||||
return { code, stdout, stderr: errors.map(renderError).join('') };
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.331",
|
||||
"version": "1.91.332",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* The agent role (lib/agent-model.ts, gstack-models resolve --role agent): a
|
||||
* user's model preference for gstack's working agents. Pins the value grammar
|
||||
* shared with bin/gstack-config, the precedence (request, this machine's
|
||||
* config.yaml, the preferences file, host), that a bad agent_model never stops
|
||||
* the other roles, the preferences file's parse rules, and the Capy wiring:
|
||||
* the session derives the file beside the Drive skill (any wrapper that exports
|
||||
* GSTACK_CAPY_WRAPPER_PATH, wrapper 3 included) and --check copies nothing.
|
||||
*/
|
||||
import { afterEach, 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 { AgentModelError, MODEL_FAMILIES, parseAgentModel, readPreferencesFile, renderAgentModel, resolveAgentModel } from '../lib/agent-model';
|
||||
import { MODEL_CATALOG } from '../lib/model-catalog';
|
||||
import { modelsMain } from '../lib/model-policy-cli';
|
||||
|
||||
const REPO = path.resolve(import.meta.dir, '..');
|
||||
const bases: string[] = [];
|
||||
afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); });
|
||||
function tmp(): string {
|
||||
const b = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'agent-model-')));
|
||||
bases.push(b);
|
||||
return b;
|
||||
}
|
||||
function env(base: string, extra: Record<string, string> = {}): Record<string, string> {
|
||||
const root = path.join(base, 'state');
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
return { HOME: base, GSTACK_STATE_ROOT: root, ...extra };
|
||||
}
|
||||
const setConfig = (e: Record<string, string>, text: string) => fs.writeFileSync(path.join(e.GSTACK_STATE_ROOT, 'config.yaml'), text);
|
||||
const config = (e: Record<string, string>, args: string[]) => spawnSync(path.join(REPO, 'bin/gstack-config'), args, { encoding: 'utf8', env: { ...process.env, ...e }, timeout: 30_000 });
|
||||
|
||||
describe('value grammar', () => {
|
||||
test('host, a family and <provider>/<model> parse; anything else is refused', () => {
|
||||
expect(parseAgentModel('host')).toEqual({ kind: 'host' });
|
||||
expect(parseAgentModel(' opus ')).toEqual({ kind: 'model', provider: 'anthropic', model: 'claude-opus-5-5', family: 'opus' });
|
||||
expect(parseAgentModel('OPUS')).toMatchObject({ family: 'opus' });
|
||||
expect(parseAgentModel('Host')).toEqual({ kind: 'host' });
|
||||
expect(parseAgentModel('openai/gpt-6.1-sol')).toEqual({ kind: 'model', provider: 'openai', model: 'gpt-6.1-sol', family: null });
|
||||
for (const bad of ['', 'opux', 'claude-opus-5-5', 'Anthropic/x', 'anthropic/', '/x', 'anthropic/a b', 'anthropic/x\u001b[31m']) expect(parseAgentModel(bad)).toBeNull();
|
||||
});
|
||||
|
||||
test('every family the tier catalog also lists names the catalog model, so a catalog bump moves the family too', () => {
|
||||
for (const entry of MODEL_CATALOG) {
|
||||
const family = MODEL_FAMILIES.find(f => f.provider === entry.provider && entry.model.includes(f.family));
|
||||
expect(family?.model, entry.model).toBe(entry.model);
|
||||
}
|
||||
});
|
||||
|
||||
test('bin/gstack-config accepts and refuses exactly what the resolver does', () => {
|
||||
const e = env(tmp());
|
||||
for (const value of ['host', 'Host', 'opus', 'OPUS', 'Sol', 'sonnet', 'terra', 'anthropic/claude-opus-5-5', 'openai/gpt-6.1-sol', 'opux', 'claude-opus-5-5', 'Anthropic/x', 'anthropic/a b', 'anthropic/']) {
|
||||
const r = config(e, ['set', 'agent_model', value]);
|
||||
expect(r.status === 0, value + r.stderr).toBe(parseAgentModel(value) !== null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('precedence and sources', () => {
|
||||
test('host when nothing is set; the preferences file, then config.yaml, then a one-job request win in turn', () => {
|
||||
const base = tmp();
|
||||
const prefs = path.join(base, 'skill', 'preferences.yaml');
|
||||
const e = env(base, { GSTACK_PREFERENCES_FILE: prefs });
|
||||
expect(resolveAgentModel({ env: e })).toMatchObject({ status: 'host', id: null, source: { kind: 'default' } });
|
||||
fs.mkdirSync(path.dirname(prefs), { recursive: true });
|
||||
fs.writeFileSync(prefs, '# my picks\nagent_model: opus\n');
|
||||
expect(resolveAgentModel({ env: e })).toMatchObject({ status: 'selected', id: 'anthropic/claude-opus-5-5', family: 'opus', source: { kind: 'preferences', path: prefs } });
|
||||
setConfig(e, 'agent_model: openai/gpt-6.1-sol\n');
|
||||
expect(resolveAgentModel({ env: e })).toMatchObject({ id: 'openai/gpt-6.1-sol', source: { kind: 'config' }, sources: { config: 'openai/gpt-6.1-sol', preferences: 'opus' } });
|
||||
expect(resolveAgentModel({ env: e, request: 'fable' })).toMatchObject({ id: 'anthropic/claude-fable-5-1', source: { kind: 'request', path: null } });
|
||||
expect(resolveAgentModel({ env: e, request: 'host' })).toMatchObject({ status: 'host', source: { kind: 'request' } });
|
||||
});
|
||||
|
||||
test('a one-job request is never saved, and a local unset falls back to the preferences file', () => {
|
||||
const base = tmp();
|
||||
const prefs = path.join(base, 'preferences.yaml');
|
||||
fs.writeFileSync(prefs, 'agent_model: opus\n');
|
||||
const e = env(base, { GSTACK_PREFERENCES_FILE: prefs });
|
||||
const r = modelsMain(['resolve', '--role', 'agent', '--request', 'sol'], e);
|
||||
expect(r.code, r.stderr).toBe(0);
|
||||
expect(r.stdout).toMatch(/^agent: openai\/gpt-6\.1-sol \(sol\) - requested for this job, not saved$/m);
|
||||
expect(fs.existsSync(path.join(e.GSTACK_STATE_ROOT, 'config.yaml'))).toBe(false);
|
||||
expect(fs.readFileSync(prefs, 'utf8')).toBe('agent_model: opus\n');
|
||||
expect(config(e, ['set', 'agent_model', 'host']).status).toBe(0);
|
||||
expect(resolveAgentModel({ env: e })).toMatchObject({ status: 'host', source: { kind: 'config' } });
|
||||
expect(config(e, ['unset', 'agent_model']).status).toBe(0);
|
||||
expect(resolveAgentModel({ env: e })).toMatchObject({ id: 'anthropic/claude-opus-5-5', source: { kind: 'preferences' } });
|
||||
});
|
||||
|
||||
test('a broken preferences file never blocks a request or this machine\'s config.yaml, and its raw value is never echoed', () => {
|
||||
const base = tmp();
|
||||
const prefs = path.join(base, 'preferences.yaml');
|
||||
fs.writeFileSync(prefs, 'agent_model: opus\nagent_model: sol\n');
|
||||
const e = env(base, { GSTACK_PREFERENCES_FILE: prefs });
|
||||
expect(() => resolveAgentModel({ env: e })).toThrow(/sets agent_model twice/);
|
||||
const requested = resolveAgentModel({ env: e, request: 'sonnet' });
|
||||
expect(requested).toMatchObject({ family: 'sonnet', source: { kind: 'request' } });
|
||||
expect(requested.warnings.join('\n')).toContain('sets agent_model twice');
|
||||
setConfig(e, 'agent_model: opus\n');
|
||||
expect(resolveAgentModel({ env: e })).toMatchObject({ family: 'opus', source: { kind: 'config' } });
|
||||
fs.writeFileSync(prefs, 'agent_model: x\u001b]0;title\u0007\u009b31m\u202e\n');
|
||||
const shown = renderAgentModel(resolveAgentModel({ env: e })).join('\n');
|
||||
expect(shown).not.toMatch(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/);
|
||||
});
|
||||
|
||||
test('an invalid value at the winning source is an error naming the source and the fix, never a silent fallback', () => {
|
||||
const e = env(tmp());
|
||||
setConfig(e, 'agent_model: opux\n');
|
||||
expect(() => resolveAgentModel({ env: e })).toThrow(AgentModelError);
|
||||
const r = modelsMain(['resolve', '--role', 'agent', '--json'], e);
|
||||
expect(r.code).toBe(1);
|
||||
const doc = JSON.parse(r.stdout);
|
||||
expect(doc).toMatchObject({ ok: false, agent: null });
|
||||
expect(doc.errors[0]).toMatchObject({ reason: 'invalid_agent_model', key: 'agent_model' });
|
||||
expect(doc.errors[0].problem).toContain(path.join(e.GSTACK_STATE_ROOT, 'config.yaml'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('a bad agent_model never stops the other roles', () => {
|
||||
test('plan-review, implementation and eval-arms resolve, and list reports the agent error without failing', () => {
|
||||
const base = tmp();
|
||||
const e = env(base);
|
||||
setConfig(e, 'agent_model: opux\n');
|
||||
for (const args of [['resolve', '--role', 'plan-review', '--provider', 'openai'], ['resolve', '--role', 'implementation'], ['resolve', '--role', 'eval-arms']]) {
|
||||
const r = modelsMain(args, e, 'linux', base);
|
||||
expect(r.code, args.join(' ') + r.stderr).toBe(0);
|
||||
}
|
||||
const list = modelsMain(['list'], e, 'linux', base);
|
||||
expect(list.code).toBe(0);
|
||||
expect(list.stdout).toMatch(/^agent: agent_model 'opux' in .* is not a model gstack can use$/m);
|
||||
const json = JSON.parse(modelsMain(['list', '--json'], e, 'linux', base).stdout);
|
||||
expect(json.ok).toBe(true);
|
||||
expect(json.agent.error.reason).toBe('invalid_agent_model');
|
||||
expect(json.selections.every((s: { role: string }) => s.role !== 'agent')).toBe(true);
|
||||
});
|
||||
|
||||
test('list --json adds a top-level agent block and leaves selections as they were', () => {
|
||||
const base = tmp();
|
||||
const e = env(base);
|
||||
const before = JSON.parse(modelsMain(['list', '--json'], e, 'linux', base).stdout);
|
||||
setConfig(e, 'agent_model: opus\n');
|
||||
const after = JSON.parse(modelsMain(['list', '--json'], e, 'linux', base).stdout);
|
||||
expect(after.selections).toEqual(before.selections);
|
||||
expect(after.agent).toMatchObject({ role: 'agent', id: 'anthropic/claude-opus-5-5' });
|
||||
expect(after.agent.exemptions.join(' ')).toMatch(/plan-review.*eval-arms/);
|
||||
});
|
||||
|
||||
test('usage: --request belongs to the agent role only, and the agent role takes no --provider', () => {
|
||||
const e = env(tmp());
|
||||
expect(modelsMain(['resolve', '--role', 'implementation', '--request', 'opus'], e).code).toBe(2);
|
||||
expect(modelsMain(['resolve', '--role', 'agent', '--provider', 'openai'], e).code).toBe(2);
|
||||
expect(modelsMain(['resolve', '--role', 'agent', '--request', 'opux'], e).code).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the preferences file', () => {
|
||||
test('comments, blanks and quotes are fine; unknown keys and stray lines warn; a second agent_model is an error', () => {
|
||||
const f = path.join(tmp(), 'preferences.yaml');
|
||||
fs.writeFileSync(f, '# owner preferences\n\nagent_model: "opus"\ntelemetry: on\nnot a pair\n');
|
||||
const read = readPreferencesFile(f);
|
||||
expect(read.value).toBe('opus');
|
||||
expect(read.warnings).toHaveLength(2);
|
||||
expect(read.warnings[0]).toContain(":4 sets 'telemetry', which gstack does not read from preferences; ignored");
|
||||
expect(read.warnings[1]).toContain(":5 is not a 'key: value' line; ignored");
|
||||
fs.writeFileSync(f, 'agent_model: opus\nagent_model: sol\n');
|
||||
expect(() => readPreferencesFile(f)).toThrow(/sets agent_model twice/);
|
||||
});
|
||||
|
||||
test('CRLF line endings, a byte-order mark and a trailing comment still read the value', () => {
|
||||
const f = path.join(tmp(), 'preferences.yaml');
|
||||
fs.writeFileSync(f, '\ufeff# owner preferences\r\nagent_model: opus # my pick\r\n');
|
||||
expect(readPreferencesFile(f)).toEqual({ value: 'opus', warnings: [] });
|
||||
});
|
||||
|
||||
test('a missing file is silent; an oversized file or a directory is ignored with a warning', () => {
|
||||
const base = tmp();
|
||||
expect(readPreferencesFile(path.join(base, 'absent.yaml'))).toEqual({ value: null, warnings: [] });
|
||||
const big = path.join(base, 'big.yaml');
|
||||
fs.writeFileSync(big, 'agent_model: opus\n' + '#'.repeat(5000));
|
||||
const read = readPreferencesFile(big);
|
||||
expect(read.value).toBeNull();
|
||||
expect(read.warnings[0]).toContain('over 4096 bytes');
|
||||
expect(readPreferencesFile(base).warnings[0]).toContain('is not a file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Capy wiring', () => {
|
||||
test('the session derives preferences.yaml beside the Drive skill from the wrapper path, in --check too, and copies nothing', () => {
|
||||
const base = tmp();
|
||||
const root = path.join(base, 'gstack');
|
||||
for (const rel of ['bin/gstack-capy-session', 'bin/gstack-install-verdict.sh']) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.copyFileSync(path.join(REPO, rel), path.join(root, rel));
|
||||
}
|
||||
fs.writeFileSync(path.join(root, 'bin/gstack-capy-install'), '#!/usr/bin/env bash\necho "PREFS=${GSTACK_PREFERENCES_FILE:-unset}"\necho "gstack: ok 1.0.0 for=none"\n', { mode: 0o755 });
|
||||
const skill = path.join(base, 'drive', 'skills', 'gstack');
|
||||
fs.mkdirSync(path.join(skill, 'scripts'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skill, 'preferences.yaml'), 'agent_model: opus\n');
|
||||
const before = fs.readdirSync(skill).sort();
|
||||
const run = (extra: Record<string, string>) => spawnSync('bash', [path.join(root, 'bin/gstack-capy-session'), '--check', '--root', root, '--for', 'review'],
|
||||
{ encoding: 'utf8', timeout: 30_000, env: { PATH: process.env.PATH ?? '', HOME: base, ...extra } });
|
||||
const r = run({ GSTACK_CAPY_WRAPPER_REV: '3', GSTACK_CAPY_WRAPPER_PATH: path.join(skill, 'scripts', 'install.sh') });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('PREFS=' + path.join(skill, 'preferences.yaml'));
|
||||
expect(fs.readdirSync(skill).sort()).toEqual(before);
|
||||
expect(run({}).stdout).toContain('PREFS=unset');
|
||||
const inCheckout = run({ GSTACK_CAPY_WRAPPER_REV: '3', GSTACK_CAPY_WRAPPER_PATH: path.join(root, 'capy', 'skill', 'gstack', 'scripts', 'install.sh') });
|
||||
expect(inCheckout.stdout).toContain('PREFS=unset');
|
||||
});
|
||||
});
|
||||
@@ -479,3 +479,60 @@ describe('capy skill: vendored text and support file', () => {
|
||||
expect([...codes].filter(c => !(c in RESULT_CODES))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capy skill: factory.md, the auto-loaded "how we work" page', () => {
|
||||
const FACTORY = path.join(SKILL, 'factory.md');
|
||||
/** Problems with a factory page's citations: [path: Heading, Heading; path2], [/skill], and plain repo paths in prose. Bare SKILL.md is the sibling file. */
|
||||
function citationProblems(text: string): string[] {
|
||||
const problems: string[] = [];
|
||||
const headingsOf = (file: string) => new Set(fs.readFileSync(file, 'utf8').split('\n').filter(l => /^#{1,6} /.test(l)).map(l => l.replace(/^#+ /, '').trim()));
|
||||
const resolve = (p: string) => p === 'SKILL.md' ? path.join(SKILL, 'SKILL.md') : path.join(REPO, p);
|
||||
for (const m of text.matchAll(/\[([^\]]+)\]/g)) {
|
||||
for (const part of m[1].split(';').map(s => s.trim())) {
|
||||
if (part.startsWith('/')) {
|
||||
if (!fs.existsSync(path.join(REPO, part.slice(1), 'SKILL.md'))) problems.push(`unknown skill ${part}`);
|
||||
continue;
|
||||
}
|
||||
const [file, headings] = part.split(/:\s*/, 2);
|
||||
if (!fs.existsSync(resolve(file))) { problems.push(`missing file ${file}`); continue; }
|
||||
const known = headingsOf(resolve(file));
|
||||
const list = !headings ? [] : known.has(headings.trim()) ? [headings.trim()] : headings.split(',').map(s => s.trim()).filter(Boolean);
|
||||
for (const h of list) if (!known.has(h)) problems.push(`missing heading ${file}: ${h}`);
|
||||
}
|
||||
}
|
||||
for (const m of text.matchAll(/\b((?:capy|docs|lib|bin)\/[A-Za-z0-9_./-]+\.md)\b/g)) {
|
||||
if (!fs.existsSync(path.join(REPO, m[1]))) problems.push(`missing file ${m[1]}`);
|
||||
}
|
||||
return [...new Set(problems)];
|
||||
}
|
||||
|
||||
test('it fits the 3,300-byte budget, and every citation, heading and skill it names resolves', () => {
|
||||
const text = fs.readFileSync(FACTORY, 'utf8');
|
||||
expect(Buffer.byteLength(text)).toBeLessThanOrEqual(3300);
|
||||
expect(text).toMatch(/^# How we work/);
|
||||
expect(citationProblems(text)).toEqual([]);
|
||||
});
|
||||
|
||||
test('negative controls: a missing file, a missing heading and an unknown skill are each caught', () => {
|
||||
expect(citationProblems('[docs/no-such-page.md: Anything]')).toEqual(['missing file docs/no-such-page.md']);
|
||||
expect(citationProblems('[docs/captain.md: No Such Heading]')).toEqual(['missing heading docs/captain.md: No Such Heading']);
|
||||
expect(citationProblems('[/no-such-skill]')).toEqual(['unknown skill /no-such-skill']);
|
||||
expect(citationProblems('see capy/no-such.md')).toEqual(['missing file capy/no-such.md']);
|
||||
});
|
||||
|
||||
test('it ships to every Capy user: no crew codes or owner names, a model preference instead of a model rule', () => {
|
||||
const text = fs.readFileSync(FACTORY, 'utf8');
|
||||
expect(text).not.toMatch(/\b(?:GSTA|GBRA|HARN|TRUT|FOUN|ALPH|GST)-\d+/);
|
||||
expect(text).not.toMatch(/\bGarry\b/);
|
||||
expect(text).toContain('gstack-models resolve --role agent');
|
||||
expect(text).not.toMatch(/no sonnet/i);
|
||||
expect(fs.readFileSync(path.join(REPO, 'docs/model-policy.md'), 'utf8')).not.toMatch(/no sonnet/i);
|
||||
});
|
||||
|
||||
test('SKILL.md points every session at it from Session start, and names preferences.yaml as the one file an agent may write in the skill folder', () => {
|
||||
const text = fs.readFileSync(path.join(SKILL, 'SKILL.md'), 'utf8');
|
||||
const sessionStart = text.slice(text.indexOf('## Session start'), text.indexOf('## Launch every gstack command'));
|
||||
expect(sessionStart).toContain('read `capy/skill/gstack/factory.md` in the checkout');
|
||||
expect(sessionStart).toMatch(/Never write the skill folder yourself; the one exception is its `preferences\.yaml`/);
|
||||
});
|
||||
});
|
||||
@@ -514,6 +514,18 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd
|
||||
expect(conflict.stderr).toContain('(RUNTIME_PIN_CONFLICT; ');
|
||||
});
|
||||
|
||||
test('the session GSTACK_PREFERENCES_FILE is written into the launch file, so every gstack command finds the owner preferences; without it no line is written', () => {
|
||||
const f = fixture();
|
||||
const prefs = '/drive/skills/gstack/preferences.yaml';
|
||||
const r = run(f, ['--for', 'autoplan'], { GSTACK_PREFERENCES_FILE: prefs });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
const launch = verdicts(f)[0].replace(/\.json$/, '.env');
|
||||
expect(fs.readFileSync(launch, 'utf8')).toContain("export GSTACK_PREFERENCES_FILE='" + prefs + "'");
|
||||
const plain = run(f, ['--for', 'autoplan']);
|
||||
expect(plain.status, plain.stdout + plain.stderr).toBe(0);
|
||||
expect(fs.readFileSync(launch, 'utf8')).not.toContain('GSTACK_PREFERENCES_FILE');
|
||||
});
|
||||
|
||||
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']);
|
||||
|
||||
Reference in new issue
Block a user