mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 05:56:37 +01:00
v1.91.101.0 fix: close the field wave's residual gaps (#3173)
The field wave's residual gaps are closed. v1.91.96.0 through v1.91.100.0 shipped the install-honesty and unattended-`/autoplan` wave (`docs/designs/GSTACK_FIELD_WAVE_2026_10_12.md`); running that release on a Capy machine left six gaps, each reproduced here with a test that failed first.
This commit is contained in:
1 parent
15ebd85cdd
commit
47d639abff
49 files changed
+1111
-132
No files matched your search
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.101.0] - 2026-10-13
|
||||
|
||||
**The field wave's residual gaps are closed.** v1.91.96.0 through v1.91.100.0 shipped the install-honesty and unattended-`/autoplan` wave (`docs/designs/GSTACK_FIELD_WAVE_2026_10_12.md`); running that release on a Capy machine left six gaps, each reproduced here with a test that failed first.
|
||||
|
||||
### What this means for you
|
||||
|
||||
- **`all` no longer overrides a recommendation to change the plan.** `gstack-autoplan answer <rev> all` resolves only the items the reviewers recommend keeping; an item recommended `b` stays pending (`GATE_PENDING: <ids> unanswered …`) with the hint `all does not answer <id> (recommended b changes the plan); reply <id>a or <id>b yourself`. `all except <id>b` still answers everything in one line.
|
||||
- **Every gate answer names who gave it.** `answer` takes `--by <human|agent:<id>>`; an unattended run refuses an answer without it (`ANSWER_BY_REQUIRED`, exit 3), and `decisions.jsonl` records `answered_by` so a review record can tell the owner's call from a delegate's.
|
||||
- **Later reviewers see the earlier consensus.** The DX and Design prompts carry the CEO consensus (and each later phase every closed predecessor's), the way Eng already did, so a reopened CEO decision reaches every phase that depends on it.
|
||||
- **An upgrade never moves a checkout under a live session.** Each `gstack-skill-start` touches an install-scoped lease (`bin/gstack-lease`, `<git dir>/gstack-leases/<harness pid>`, pruned after 120 minutes; the `/autoplan` driver renews it on every mutating command). `gstack-upgrade` refuses with `UPGRADE_BUSY` (exit 3) while a lease whose pid is alive and outside its own process chain exists; `gstack-lease list` shows them and `GSTACK_UPGRADE_FORCE=1` overrides.
|
||||
- **Capy sessions stay at the tip, not at the floor.** `GSTACK_MIN_REVISION` is the floor below which the trailer fails and the frozen bootstrapper fast-forwards. Above it, `bin/gstack-capy-session` fast-forwards any clean, unpinned checkout behind origin at install-mode session start (`SELF_UPGRADE: done <old> -> <new>`, then installs from the new revision); a refusal prints `SELF_UPGRADE: skipped (<reason>)` and the session continues on the installed revision. `--check` never moves the checkout and reports `gstack-capy-session: behind origin/<branch>: … fix: …`. `GSTACK_CAPY_SELF_UPGRADE=off` disables only this fast-forward; `GSTACK_CAPY_AUTO_UPGRADE=0` disables every one.
|
||||
- **gstack's own tools run on the Bun that launched them.** `lib/autoplan-prompts.ts`, `lib/restamp.ts` and `bin/gstack-gbrain-sync.ts` spawn `process.execPath` instead of whatever `bun` the PATH resolves, so a Capy machine whose PATH lacks `~/.bun/bin` no longer fails mid-workflow (`lib/autoplan-admission.ts` keeps probing the PATH Bun on purpose; that probe is the point).
|
||||
|
||||
- **`/ship` and `/review` load the QA methods as three awaited steps.** The exploratory preflight is now an ordered list (entrypoint, then `scope.md`, then each listed method, each in its own response) and names the smoke guard start, every capture and every plan check as probes that wait for them; the entrypoint alone completes nothing.
|
||||
|
||||
### For contributors
|
||||
|
||||
- `test/autoplan-residuals.test.ts` pins the `all` rule, the carried consensus, the `process.execPath` rule under `lib/` and `bin/*.ts`, and `--by`; `test/gstack-lease.test.ts` covers the lease; `test/gstack-upgrade-bin.test.ts` and `test/capy-skill-wrapper.test.ts` cover `UPGRADE_BUSY` and `SELF_UPGRADE:`.
|
||||
- `bin/gstack-harness-pid.sh` is the one harness-pid derivation, sourced by `bin/gstack-skill-start` and `bin/gstack-lease`.
|
||||
- The test preload and `test/helpers/hermetic-env.ts` set a per-run `GSTACK_LEASE_DIR`, so no test leases the developer's checkout.
|
||||
|
||||
## [1.91.100.0] - 2026-10-13
|
||||
|
||||
**A gbrain LongMemEval call plan now prices in full.** gbrain's `eval longmemeval --call-plan` (gbrain#6638) writes a `GSTACK_EVAL_CALLS` file for every lane of the run. `gstack-eval-plan price-gate` verified its binding but stopped at `EVAL_PRICE_MISSING` with the total unknown: it had no row for the judge (`gpt-4o`) or the reranker (`rerank-2.5`), and it did not recognize the dated Claude id gbrain resolves (`claude-haiku-4-5-20251001`). On the gbrain fixture plan (`--mode tokenmax --judge`) all seven lanes now price, for a total of $0.447.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.100.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.101.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,
|
||||
|
||||
+6
-3
@@ -29,9 +29,12 @@ line yourself; gstack never writes the plan.
|
||||
2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`).
|
||||
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with
|
||||
`gstack-gate render <dir>/gate.json`. You never approve the gate for the owner: relay it.
|
||||
4. `answer` with the owner's reply. `APPLY: <id> <title> -> <change>` lines are accepted plan
|
||||
changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until then `next`
|
||||
refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
|
||||
4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or
|
||||
`--by agent:<id>` (you relayed or decided them); without it an unattended run refuses
|
||||
(`ANSWER_BY_REQUIRED`). `all` leaves every item recommended `b` pending: only the owner's
|
||||
own token changes their stated direction. `APPLY: <id> <title> -> <change>` lines are
|
||||
accepted plan changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until
|
||||
then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
|
||||
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`.
|
||||
|
||||
### Commands
|
||||
|
||||
@@ -26,9 +26,12 @@ line yourself; gstack never writes the plan.
|
||||
2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`).
|
||||
3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=<n>`); render it with
|
||||
`gstack-gate render <dir>/gate.json`. You never approve the gate for the owner: relay it.
|
||||
4. `answer` with the owner's reply. `APPLY: <id> <title> -> <change>` lines are accepted plan
|
||||
changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until then `next`
|
||||
refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
|
||||
4. `answer` with the owner's reply and `--by human` (the owner typed the tokens) or
|
||||
`--by agent:<id>` (you relayed or decided them); without it an unattended run refuses
|
||||
(`ANSWER_BY_REQUIRED`). `all` leaves every item recommended `b` pending: only the owner's
|
||||
own token changes their stated direction. `APPLY: <id> <title> -> <change>` lines are
|
||||
accepted plan changes: edit the plan, then `revise` (the printed `NEXT:` names it). Until
|
||||
then `next` refuses with `PLAN_AMEND_PENDING` and `export` will not mark the run complete.
|
||||
5. `REOPENED: <phases>` means loop `next` again (Eng last). `APPROVED:` means `export`.
|
||||
|
||||
### Commands
|
||||
|
||||
+5
-4
@@ -26,7 +26,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { answerGate } from '../lib/autoplan-gate';
|
||||
import { admit } from '../lib/autoplan-admission';
|
||||
import { admit, touchInstallLease } from '../lib/autoplan-admission';
|
||||
import { exportRun } from '../lib/autoplan-export';
|
||||
import { migrateRun } from '../lib/autoplan-migrate';
|
||||
import { reviseRun } from '../lib/autoplan-revise';
|
||||
@@ -60,7 +60,7 @@ DRIVER: ${driverFile()}
|
||||
--incorporates discharges the named accepted changes (the plan must change); --withdraw (alone or with --plan) drops one
|
||||
run --out <dir> --plan <file> --native-model <id> --outside-model <id> [--runner codex-cli|api] [--native-runner api] ...
|
||||
next in a loop, only when a runner executes both voices here; --deadline cancels the children it started
|
||||
answer --out <dir> --gate-rev <rev> --reply "<all | d3b uc1a | all except d3b>" [--json]
|
||||
answer --out <dir> --gate-rev <rev> --reply "<all | d3b uc1a | all except d3b>" --by <human|agent:<id>> [--json]
|
||||
resume --out <dir> [--json] status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id>
|
||||
contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word)
|
||||
|
||||
@@ -99,6 +99,7 @@ function nextLine(state: RunState, out: string): string {
|
||||
*/
|
||||
function mutating(out: string, json: boolean, fn: () => number): number {
|
||||
const admission = admit(process.env);
|
||||
touchInstallLease(process.env);
|
||||
return withRunLock(out, () => {
|
||||
const notices = [...admission.lines];
|
||||
const record = (state: RunState) => {
|
||||
@@ -245,12 +246,12 @@ async function main(argv: string[]): Promise<number> {
|
||||
}, { steal: true });
|
||||
}
|
||||
if (cmd === 'answer') {
|
||||
const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply');
|
||||
const rev = flag(rest, '--gate-rev'); const reply = flag(rest, '--reply'); const by = flag(rest, '--by');
|
||||
if (reply === undefined) return usage('answer needs --reply "<text>"');
|
||||
if (rev === undefined) { const s = readState(out); process.stderr.write(resultLine('gstack-autoplan answer', 'GATE_REV_STALE', `no --gate-rev given; current gate_rev=${s.gate_rev ?? 0}`) + '\n'); return EXIT.refused; }
|
||||
return mutating(out, json, () => {
|
||||
const state = readState(out);
|
||||
const r = answerGate(state, Number(rev), reply);
|
||||
const r = answerGate(state, Number(rev), reply, by);
|
||||
writeState(state);
|
||||
const lines = [...r.lines];
|
||||
if (r.outcome === 'reopened') lines.push(nextLine(state, out));
|
||||
|
||||
+56
-1
@@ -12,6 +12,20 @@
|
||||
# without anyone recopying the Drive skill; that includes the stale-copy owner
|
||||
# action, read from capy/support.env through gstack_support_env_get.
|
||||
#
|
||||
# Always current when online: the bootstrapper moves a checkout only when it
|
||||
# is below GSTACK_MIN_REVISION (the floor). Above the floor, this half keeps
|
||||
# the checkout at the tip of origin's default branch: in install mode, when
|
||||
# the bootstrapper's FETCH_HEAD is ahead of HEAD, it runs
|
||||
# bin/gstack-upgrade --ff-only-no-reinstall (every refusal of gstack-upgrade
|
||||
# applies: dirty, diverged, pinned, Bun hold, a live workflow lease) and on
|
||||
# success re-execs the NEW checkout's copy of this script with the same
|
||||
# arguments (GSTACK_CAPY_SELF_UPGRADED=1 stops a second attempt) before the
|
||||
# installer runs. Lines: `SELF_UPGRADE: done <old> -> <new>` | `skipped (<reason>)`
|
||||
# | `none (current)` | `disabled (<the variable: GSTACK_CAPY_SELF_UPGRADE=off or
|
||||
# GSTACK_CAPY_AUTO_UPGRADE=0>)`.
|
||||
# --check never moves the checkout; behind origin it prints
|
||||
# `gstack-capy-session: behind origin/<branch>: <installed> -> <remote> (<n> commits); fix: ...`.
|
||||
#
|
||||
# Usage: gstack-capy-session [--check] --root <checkout> [gstack-capy-install arguments]
|
||||
# --check certify only (bin/gstack-capy-install --check: the doctor with the
|
||||
# launch PATH and the remote minimum, the verdict, the launch line)
|
||||
@@ -19,8 +33,14 @@
|
||||
# The last line is the `gstack:` trailer; the exit status is the installer's.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
CHECK=0 SKILL=""
|
||||
prev=""
|
||||
for a in "$@"; do [ "$prev" = --root ] && ROOT="$a"; prev="$a"; done
|
||||
for a in "$@"; do
|
||||
[ "$prev" = --root ] && ROOT="$a"
|
||||
case "$prev" in --for|--only) SKILL="$a" ;; esac
|
||||
[ "$a" = --check ] && CHECK=1
|
||||
prev="$a"
|
||||
done
|
||||
INSTALLER="$ROOT/bin/gstack-capy-install"
|
||||
if [ ! -x "$INSTALLER" ]; then
|
||||
echo "gstack: fail installer — $INSTALLER is missing; fix: bash ${GSTACK_CAPY_WRAPPER_PATH:-<skill>/scripts/install.sh} (re-clones or fast-forwards)"
|
||||
@@ -33,4 +53,39 @@ MIN_WRAPPER=$(gstack_support_env_get "$(gstack_source_root "$ROOT/bin")" GSTACK_
|
||||
if [ -n "$MIN_WRAPPER" ] && [ "${GSTACK_CAPY_WRAPPER_REV:-0}" -lt "$MIN_WRAPPER" ] 2>/dev/null; then
|
||||
echo "gstack-capy-skill: owner action — Drive copy is wrapper ${GSTACK_CAPY_WRAPPER_REV:-0}, minimum $MIN_WRAPPER; copy $ROOT/capy/skill/gstack/ (CAPY_WRAPPER_STALE)"
|
||||
fi
|
||||
|
||||
# Behind origin? Only when the bootstrapper fetched (a known remote minimum
|
||||
# means FETCH_HEAD is origin's default branch tip) and that tip is not already
|
||||
# in HEAD.
|
||||
behind() {
|
||||
[ -n "${GSTACK_CAPY_REMOTE_MIN:-}" ] && [ "$GSTACK_CAPY_REMOTE_MIN" != unknown ] || return 1
|
||||
git -C "$ROOT" rev-parse -q --verify FETCH_HEAD >/dev/null 2>&1 || return 1
|
||||
! git -C "$ROOT" merge-base --is-ancestor FETCH_HEAD HEAD 2>/dev/null
|
||||
}
|
||||
disabled() { echo "SELF_UPGRADE: disabled ($1)"; } # VAR — the switch that turned the fast-forward off
|
||||
if [ "$CHECK" -eq 1 ]; then
|
||||
if behind; then
|
||||
BRANCH=$(git -C "$ROOT" symbolic-ref -q --short HEAD 2>/dev/null || echo HEAD)
|
||||
echo "gstack-capy-session: behind origin/$BRANCH: $(head -1 "$ROOT/VERSION" 2>/dev/null) -> $(git -C "$ROOT" show FETCH_HEAD:VERSION 2>/dev/null | head -1) ($(git -C "$ROOT" rev-list --count HEAD..FETCH_HEAD 2>/dev/null || echo '?') commits); fix: bash ${GSTACK_CAPY_WRAPPER_PATH:-<skill>/scripts/install.sh}${SKILL:+ --for $SKILL} (fast-forwards)"
|
||||
fi
|
||||
elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then
|
||||
if [ "${GSTACK_CAPY_SELF_UPGRADE:-}" = off ]; then disabled GSTACK_CAPY_SELF_UPGRADE
|
||||
elif [ "${GSTACK_CAPY_AUTO_UPGRADE:-1}" = 0 ]; then disabled GSTACK_CAPY_AUTO_UPGRADE
|
||||
elif [ -z "${GSTACK_CAPY_REMOTE_MIN:-}" ] || [ "$GSTACK_CAPY_REMOTE_MIN" = unknown ]; then echo "SELF_UPGRADE: none (remote minimum unknown)"
|
||||
elif ! behind; then echo "SELF_UPGRADE: none (current)"
|
||||
elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"
|
||||
else
|
||||
OLD=$(head -1 "$ROOT/VERSION" 2>/dev/null)
|
||||
UP_OUT=$("$ROOT/bin/gstack-upgrade" --ff-only-no-reinstall 2>&1); UP_RC=$?
|
||||
[ -n "$UP_OUT" ] && printf '%s\n' "$UP_OUT"
|
||||
if [ "$UP_RC" -eq 0 ]; then
|
||||
echo "SELF_UPGRADE: done $OLD -> $(head -1 "$ROOT/VERSION" 2>/dev/null)"
|
||||
export GSTACK_CAPY_UPGRADED_FROM="$OLD" GSTACK_CAPY_SELF_UPGRADED=1
|
||||
exec "$ROOT/bin/gstack-capy-session" "$@"
|
||||
fi
|
||||
REASON=$(printf '%s\n' "$UP_OUT" | sed -n 's/^gstack-upgrade: \(.*\); nothing was changed; fix: /\1; fix: /p' | tail -1)
|
||||
[ -n "$REASON" ] || REASON=$(printf '%s\n' "$UP_OUT" | sed -n 's/^gstack-upgrade: //p' | tail -1)
|
||||
echo "SELF_UPGRADE: skipped (${REASON:-gstack-upgrade exit $UP_RC})"
|
||||
fi
|
||||
fi
|
||||
exec "$INSTALLER" "$@"
|
||||
@@ -105,6 +105,7 @@ gstack_result_anchor() {
|
||||
REVIEW_STATUS_MISMATCH) printf '%s' 'review-status-mismatch' ;;
|
||||
GATE_REV_STALE) printf '%s' 'gate-rev-stale' ;;
|
||||
GATE_REPLY_UNPARSED) printf '%s' 'gate-reply-unparsed' ;;
|
||||
ANSWER_BY_REQUIRED) printf '%s' 'answer-by-required' ;;
|
||||
COMPONENT_MISSING) printf '%s' 'doctor-component-missing' ;;
|
||||
RUNTIME_BELOW_MINIMUM) printf '%s' 'doctor-runtime-below-minimum' ;;
|
||||
PROJECT_PIN_MISMATCH) printf '%s' 'doctor-project-pin-mismatch' ;;
|
||||
@@ -186,6 +187,7 @@ gstack_result_anchor() {
|
||||
GSTACK_VERSION_CHANGED) printf '%s' 'gstack-version-changed' ;;
|
||||
ARTIFACT_GATE_UNSETTLED) printf '%s' 'artifact-gate-unsettled' ;;
|
||||
UPGRADE_REFUSED) printf '%s' 'upgrade-refused' ;;
|
||||
UPGRADE_BUSY) printf '%s' 'upgrade-busy' ;;
|
||||
UPGRADE_FETCH_FAILED) printf '%s' 'upgrade-fetch-failed' ;;
|
||||
REVISION_BELOW_MINIMUM) printf '%s' 'revision-below-minimum' ;;
|
||||
CAPY_WRAPPER_STALE) printf '%s' 'capy-wrapper-stale' ;;
|
||||
|
||||
@@ -1608,7 +1608,7 @@ function runMemoryIngest(args: CliArgs, previous: SyncState, consent: Transcript
|
||||
process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS,
|
||||
"GSTACK_SYNC_MEMORY_TIMEOUT_MS",
|
||||
);
|
||||
const result = spawnSync("bun", ingestArgs, {
|
||||
const result = spawnSync(process.execPath, ingestArgs, {
|
||||
encoding: "utf-8",
|
||||
timeout: memoryTimeoutMs,
|
||||
env: childEnv,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# shellcheck shell=bash
|
||||
# gstack-harness-pid.sh — the harness pid behind a gstack command (sourced,
|
||||
# never executed). One derivation for every writer that keys per-session
|
||||
# state on it: bin/gstack-skill-start (the state-root session marker) and
|
||||
# bin/gstack-lease (the install-scoped workflow lease), so an updater that
|
||||
# reads leases sees the same pid the session wrote. Bash 3.2 plus ps.
|
||||
#
|
||||
# gstack_harness_pid START — walk up from START (the calling shell) past
|
||||
# shell wrappers and print the first non-shell ancestor. Claude Code runs a
|
||||
# tool call under a per-call `bash -c`, whose parent is the harness; a host
|
||||
# that execs the command directly is already the parent. Without a usable ps
|
||||
# (some Windows shells) START stands. At most 8 hops; pid 0 and 1 stop.
|
||||
gstack_harness_pid() {
|
||||
_hp_pid="$1"
|
||||
_hp_hops=0
|
||||
while [ "$_hp_hops" -lt 8 ]; do
|
||||
_hp_comm=$(ps -o comm= -p "$_hp_pid" 2>/dev/null | head -1) || break
|
||||
_hp_comm=${_hp_comm##*/}; _hp_comm=${_hp_comm#-}
|
||||
case "$_hp_comm" in bash|sh|zsh|dash|ksh|mksh|ash|fish|busybox) ;; *) break ;; esac
|
||||
_hp_up=$(ps -o ppid= -p "$_hp_pid" 2>/dev/null | tr -d ' ')
|
||||
case "$_hp_up" in ''|*[!0-9]*|0|1) break ;; esac
|
||||
_hp_pid="$_hp_up"
|
||||
_hp_hops=$((_hp_hops + 1))
|
||||
done
|
||||
printf '%s\n' "$_hp_pid"
|
||||
}
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-lease — the workflow lease on a shared gstack installation. Several
|
||||
# agents (eight Capy subagents, say) share one checkout; a session-start
|
||||
# fast-forward must not move it under a session mid-workflow. The state-root
|
||||
# session markers (bin/gstack-skill-start) differ per agent, so an updater
|
||||
# cannot see them; leases live in the installation's own git dir, beside
|
||||
# gstack-upgrade.pending, where every session and updater of this checkout
|
||||
# shares them.
|
||||
#
|
||||
# Usage:
|
||||
# gstack-lease touch [--root <checkout>] [--pid <p> | --from <pid>]
|
||||
# write <git dir>/gstack-leases/<pid>; pid defaults to the harness pid
|
||||
# behind this call (bin/gstack-harness-pid.sh, the same derivation the
|
||||
# session marker uses), or behind --from (a caller whose own parent is
|
||||
# short-lived passes its parent pid); leases older than 120 min are pruned
|
||||
# gstack-lease live [--root <checkout>] [--exclude-ancestry-of <pid>]
|
||||
# print `<pid> <age>m` per lease whose pid is alive and is neither the
|
||||
# given pid nor one of its ancestors (the caller's own session); exit 0
|
||||
# when any live lease exists, 1 when none
|
||||
# gstack-lease list [--root <checkout>]
|
||||
# print `<pid> <age>m alive|dead` for every lease
|
||||
# <checkout> defaults to the source checkout behind this bin (an installed
|
||||
# runtime root's .source-path, else ../). A root that is not a git checkout
|
||||
# exits 2 and writes nothing. GSTACK_LEASE_DIR overrides the lease directory
|
||||
# (hermetic tests; a dev checkout's own tests must not lease it). Bash 3.2
|
||||
# plus git, ps, find.
|
||||
set -u
|
||||
BIN="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
# shellcheck source=bin/gstack-install-verdict.sh
|
||||
. "$BIN/gstack-install-verdict.sh" 2>/dev/null || gstack_source_root() { (cd "$1/.." && pwd -P); }
|
||||
# shellcheck source=bin/gstack-harness-pid.sh
|
||||
. "$BIN/gstack-harness-pid.sh" 2>/dev/null || gstack_harness_pid() { printf '%s\n' "$1"; }
|
||||
|
||||
VERB="${1:-}"; [ $# -gt 0 ] && shift
|
||||
ROOT="" PID="" EXCLUDE="" FROM=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--root) ROOT="${2:-}"; shift ;;
|
||||
--pid) PID="${2:-}"; shift ;;
|
||||
--from) FROM="${2:-}"; shift ;;
|
||||
--exclude-ancestry-of) EXCLUDE="${2:-}"; shift ;;
|
||||
*) echo "gstack-lease: unknown argument '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
case "$VERB" in
|
||||
touch|live|list) ;;
|
||||
-h|--help|'') sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "gstack-lease: unknown verb '$VERB'" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -n "$ROOT" ] || ROOT=$(gstack_source_root "$BIN")
|
||||
GIT_DIR_ABS=$(git -C "$ROOT" rev-parse --absolute-git-dir 2>/dev/null) \
|
||||
|| { echo "gstack-lease: $ROOT is not a git checkout" >&2; exit 2; }
|
||||
DIR="${GSTACK_LEASE_DIR:-$GIT_DIR_ABS/gstack-leases}"
|
||||
|
||||
alive() { kill -0 "$1" 2>/dev/null || ps -p "$1" >/dev/null 2>&1; }
|
||||
age_min() { # FILE — minutes since its mtime
|
||||
_m=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0)
|
||||
echo $(( ( $(date +%s) - _m ) / 60 ))
|
||||
}
|
||||
|
||||
case "$VERB" in
|
||||
touch)
|
||||
[ -n "$PID" ] || PID=$(gstack_harness_pid "${FROM:-$PPID}")
|
||||
mkdir -p "$DIR" || exit 2
|
||||
: > "$DIR/$PID" || exit 2
|
||||
find "$DIR" -mmin +120 -type f -exec rm -f {} + 2>/dev/null
|
||||
exit 0 ;;
|
||||
list)
|
||||
for f in "$DIR"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
p=${f##*/}
|
||||
if alive "$p"; then echo "$p $(age_min "$f")m alive"; else echo "$p $(age_min "$f")m dead"; fi
|
||||
done
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
# live: the caller's own chain (the pid and its ancestors, up to 64 hops) never counts.
|
||||
ANCESTRY=" "
|
||||
p="$EXCLUDE"; hops=0
|
||||
while [ -n "$p" ] && [ "$hops" -lt 64 ]; do
|
||||
case "$p" in ''|*[!0-9]*|0|1) break ;; esac
|
||||
ANCESTRY="$ANCESTRY$p "
|
||||
p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ')
|
||||
hops=$((hops + 1))
|
||||
done
|
||||
FOUND=1
|
||||
for f in "$DIR"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
p=${f##*/}
|
||||
case "$ANCESTRY" in *" $p "*) continue ;; esac
|
||||
alive "$p" || continue
|
||||
echo "$p $(age_min "$f")m"
|
||||
FOUND=0
|
||||
done
|
||||
exit $FOUND
|
||||
+10
-15
@@ -52,22 +52,14 @@ while [ $# -gt 0 ]; do
|
||||
done
|
||||
[ -n "$SKILL_NAME" ] || SKILL_NAME="unknown"
|
||||
|
||||
# Harness pid: walk up from the calling shell past shell wrappers. Claude
|
||||
# Code runs this under a per-call `bash -c`, whose parent is the harness; a
|
||||
# host that execs the command directly is already the parent. Without a
|
||||
# usable ps (some Windows shells) the immediate parent stands.
|
||||
# Harness pid: walk up from the calling shell past shell wrappers
|
||||
# (bin/gstack-harness-pid.sh, shared with bin/gstack-lease so the install
|
||||
# lease and the session marker name the same pid). Without the helper the
|
||||
# immediate parent stands.
|
||||
# shellcheck source=bin/gstack-harness-pid.sh
|
||||
. "$(dirname "$0")/gstack-harness-pid.sh" 2>/dev/null || gstack_harness_pid() { printf '%s\n' "$1"; }
|
||||
if [ -z "$PARENT_PID" ]; then
|
||||
PARENT_PID="$PPID"
|
||||
_hops=0
|
||||
while [ "$_hops" -lt 8 ]; do
|
||||
_comm=$(ps -o comm= -p "$PARENT_PID" 2>/dev/null | head -1) || break
|
||||
_comm=${_comm##*/}; _comm=${_comm#-}
|
||||
case "$_comm" in bash|sh|zsh|dash|ksh|mksh|ash|fish|busybox) ;; *) break ;; esac
|
||||
_up=$(ps -o ppid= -p "$PARENT_PID" 2>/dev/null | tr -d ' ')
|
||||
case "$_up" in ''|*[!0-9]*|0|1) break ;; esac
|
||||
PARENT_PID="$_up"
|
||||
_hops=$((_hops + 1))
|
||||
done
|
||||
PARENT_PID=$(gstack_harness_pid "$PPID")
|
||||
fi
|
||||
|
||||
_SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd)
|
||||
@@ -127,6 +119,9 @@ fi
|
||||
mkdir -p "$_GH/sessions" 2>/dev/null || true
|
||||
touch "$_GH/sessions/$PARENT_PID" 2>/dev/null || true
|
||||
find "$_GH/sessions" -mmin +120 -type f -exec rm {} + 2>/dev/null || true
|
||||
# The install-scoped twin (bin/gstack-lease): what bin/gstack-upgrade reads
|
||||
# before moving a checkout other sessions share. Fail-open.
|
||||
"$_BIN/gstack-lease" touch --pid "$PARENT_PID" >/dev/null 2>&1 || true
|
||||
_SESSIONS=$(find "$_GH/sessions" -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
_PROACTIVE=$("$_BIN/gstack-config" get proactive 2>/dev/null || echo "true")
|
||||
|
||||
+25
-4
@@ -10,24 +10,32 @@
|
||||
# gstack-upgrade fast-forward, then reinstall: bin/gstack-capy-install on a
|
||||
# Capy install (the marker it writes in the git dir),
|
||||
# else ./setup --refresh-registered (setup runs migrations)
|
||||
# gstack-upgrade --ff-only-no-reinstall
|
||||
# fast-forward with every refusal below, print the upgraded
|
||||
# line and exit 0 without reinstalling (for a caller about
|
||||
# to run the installer itself: bin/gstack-capy-session)
|
||||
#
|
||||
# Refuses with exit 3 and changes nothing (UPGRADE_REFUSED) when the directory
|
||||
# is not a gstack checkout, HEAD is detached or not on the remote default
|
||||
# branch (a pin), any tracked byte is dirty, a local commit diverged, the
|
||||
# incoming release's Bun floor holds, or another updater holds the setup
|
||||
# lock; never stashes, never resets. An unreachable remote is
|
||||
# UPGRADE_FETCH_FAILED (exit 3). Prints `gstack-upgrade: upgraded <old> -> <new>`
|
||||
# before reinstalling; a failed reinstall exits 1 and the next run completes it.
|
||||
# UPGRADE_FETCH_FAILED (exit 3). While another live session holds a workflow
|
||||
# lease on this installation (bin/gstack-lease live) it refuses with
|
||||
# UPGRADE_BUSY (exit 3); GSTACK_UPGRADE_FORCE=1 overrides. Prints
|
||||
# `gstack-upgrade: upgraded <old> -> <new>` before reinstalling; a failed
|
||||
# reinstall exits 1 and the next run completes it.
|
||||
# Bash 3.2 plus git; the fetch is bounded by `timeout` when present.
|
||||
set -u
|
||||
BASH_COMPAT=50
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
CHECK=0
|
||||
CHECK=0 REINSTALL=1
|
||||
case "${1:-}" in
|
||||
--check) CHECK=1 ;;
|
||||
--ff-only-no-reinstall) REINSTALL=0 ;;
|
||||
'') ;;
|
||||
-h|--help) sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
-h|--help) sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "gstack-upgrade: unknown argument '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -88,6 +96,18 @@ if ! git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null; then
|
||||
git -C "$ROOT" merge-base --is-ancestor HEAD FETCH_HEAD 2>/dev/null \
|
||||
|| refuse "diverged: $(git -C "$ROOT" rev-list --count FETCH_HEAD..HEAD 2>/dev/null || echo '?') local commits are not on origin/$DEFAULT" "git -C $ROOT log --oneline FETCH_HEAD..HEAD shows them; move them to another branch, then re-run"
|
||||
fi
|
||||
# Workflow leases (bin/gstack-lease): sessions sharing this installation that
|
||||
# are alive and not this process's own chain. Moving the checkout under them
|
||||
# is what the lease exists to prevent; the caller's own session never counts.
|
||||
if [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \
|
||||
&& BUSY=$("$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ 2>/dev/null); then
|
||||
HOLDERS=""
|
||||
while read -r _pid _age; do HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age)"; done <<EOF_BUSY
|
||||
$BUSY
|
||||
EOF_BUSY
|
||||
echo "gstack-upgrade: live gstack sessions hold this installation: $HOLDERS; nothing was changed; fix: wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle (UPGRADE_BUSY)" >&2
|
||||
exit 3
|
||||
fi
|
||||
# shellcheck source=bin/gstack-bun-version.sh
|
||||
. "$ROOT/bin/gstack-bun-version.sh" 2>/dev/null && HOLD=$(gstack_bun_incoming_hold "$ROOT" "$INCOMING") \
|
||||
&& refuse "the incoming release holds: $HOLD" "install Bun $(git -C "$ROOT" show "$INCOMING:bin/gstack-bun-version.sh" 2>/dev/null | sed -n 's/^GSTACK_BUN_TESTED="\(.*\)"/\1/p') (bin/gstack-capy-install on Capy), then re-run"
|
||||
@@ -106,4 +126,5 @@ NEW=$(head -1 "$ROOT/VERSION")
|
||||
echo "gstack-upgrade: upgraded $INSTALLED -> $NEW"
|
||||
rm -rf "$LOCK_DIR"; trap - EXIT
|
||||
|
||||
[ "$REINSTALL" -eq 1 ] || exit 0
|
||||
reinstall "$INSTALLED"
|
||||
@@ -10,7 +10,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 <component>` row per component, a `GSTACK_LAUNCH:` line, and exactly one trailer as the last line: `gstack: ok <revision> for=<skill>` (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.
|
||||
2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, run the same command without `--check` (900-second timeout). For `pins` it installs the project's pinned Bun when the pin names a verified version (about 4 s), or stops with `RUNTIME_PIN_CONFLICT` naming the pin to raise. A clean, unpinned checkout below the supported minimum is fast-forwarded (`gstack-capy-skill: upgraded <old> -> <new>`), reinstalled and rechecked in one locked step; about 1 s when nothing changed. A pinned (`--revision`, detached), dirty or diverged checkout is never moved: the trailer names the cause and the exact commands. `GSTACK_CAPY_AUTO_UPGRADE=0` disables the fast-forward.
|
||||
2. If the trailer names `revision`, `runtime` or `pins` (`gstack: fail revision …`, `gstack: fail runtime,pins`, `gstack: fail pins`) and a checkout exists, or the output carries a `gstack-capy-session: behind origin/<branch>: …` line, run the same command without `--check` (900-second timeout). 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: below the floor the trailer names the cause and the exact commands; above it the run prints `SELF_UPGRADE: skipped (<reason>)` and continues on the installed revision. `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, the SHA-256-verified Bun 1.4.2, both host profiles without Chromium).
|
||||
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; …)` and `SKIP pins (planning-only; …)` are not failures.
|
||||
|
||||
|
||||
+7
-3
@@ -3,9 +3,13 @@
|
||||
# so a checkout too old to know today's rules learns them from the remote.
|
||||
# Parsed, never sourced: KEY=<dotted number> lines only.
|
||||
#
|
||||
# GSTACK_MIN_REVISION: the oldest revision whose installer, trailer and fix
|
||||
# lines are honest. A checkout below it is fast-forwarded at session start
|
||||
# (install mode; --check only reports). Raise it only for install-honesty or
|
||||
# GSTACK_MIN_REVISION: the floor. The oldest revision whose installer, trailer
|
||||
# and fix lines are honest: below it the trailer fails (REVISION_BELOW_MINIMUM)
|
||||
# and the bootstrapper fast-forwards a clean, unpinned checkout at install-mode
|
||||
# session start (--check only reports). It is not the upgrade trigger: any
|
||||
# checkout behind origin's default branch is fast-forwarded at install-mode
|
||||
# session start by the checkout's own bin/gstack-capy-session (SELF_UPGRADE:
|
||||
# lines; refusals skip and continue). Raise it only for install-honesty or
|
||||
# security fixes, at stamp time, to the VERSION being released
|
||||
# (docs/capy-install.md "Raising the supported minimum"); it never exceeds VERSION.
|
||||
GSTACK_MIN_REVISION=1.91.96.0
|
||||
|
||||
+30
-6
@@ -5,7 +5,7 @@ Three layers, each owned by gstack:
|
||||
| Layer | Where | Job |
|
||||
|---|---|---|
|
||||
| Bootstrapper | `capy/skill/gstack/scripts/install.sh` (copied once into the owner's Drive skill folder; frozen) | clone when absent; fetch origin's default branch and read the supported minimum from `FETCH_HEAD:capy/support.env`; in install mode fast-forward a clean, unpinned checkout below it; exec the full wrapper |
|
||||
| Full wrapper | `bin/gstack-capy-session` (moves with the checkout) | map the session start onto the installer: `--check` certifies, otherwise install or repair |
|
||||
| Full wrapper | `bin/gstack-capy-session` (moves with the checkout) | map the session start onto the installer: `--check` certifies (and reports a checkout behind origin), otherwise fast-forward a checkout behind origin (`bin/gstack-upgrade --ff-only-no-reinstall`), then install or repair |
|
||||
| Installer | `bin/gstack-capy-install` | Bun, both host profiles, the doctor, the install verdict, the launch line, the trailer |
|
||||
|
||||
The vendored skill (`capy/skill/gstack/SKILL.md`) tells a Capy agent how to use
|
||||
@@ -36,9 +36,23 @@ git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.lo
|
||||
checkout is never moved; the trailer names the cause and the exact commands
|
||||
([REVISION_BELOW_MINIMUM](troubleshooting.md#revision-below-minimum)).
|
||||
`GSTACK_CAPY_AUTO_UPGRADE=0` disables the fast-forward.
|
||||
3. When the fetch fails (offline), the bootstrapper says `remote minimum
|
||||
3. The minimum is the floor, not the trigger. Above it, a checkout behind
|
||||
origin's default branch still moves at install-mode session start: the
|
||||
full wrapper runs `bin/gstack-upgrade --ff-only-no-reinstall` (every
|
||||
refusal of `gstack-upgrade` applies), prints `SELF_UPGRADE: done <old> ->
|
||||
<new>` and re-execs the new checkout's wrapper, which installs from the
|
||||
new revision (`GSTACK_CAPY_UPGRADED_FROM` set, so setup is never skipped).
|
||||
A refusal prints `SELF_UPGRADE: skipped (<reason>)` and the session
|
||||
continues on the installed revision; the trailer stays the doctor's.
|
||||
`SELF_UPGRADE: none (current)` when nothing is ahead;
|
||||
`GSTACK_CAPY_SELF_UPGRADE=off` (or `GSTACK_CAPY_AUTO_UPGRADE=0`) prints
|
||||
`SELF_UPGRADE: disabled (<variable>)`. `--check` never moves the checkout;
|
||||
behind origin it prints `gstack-capy-session: behind origin/<branch>:
|
||||
<installed> -> <remote> (<n> commits); fix: bash <skill>/scripts/install.sh
|
||||
[--for <skill>] (fast-forwards)` and the agent runs that fix.
|
||||
4. When the fetch fails (offline), the bootstrapper says `remote minimum
|
||||
unknown (fetch failed)` and checks the local install only; it never claims
|
||||
the minimum.
|
||||
the minimum, and nothing is fast-forwarded.
|
||||
|
||||
The upgrade target is always the tip of the default branch: every commit there
|
||||
is a stamped release that passed CI. Recovery runs forward, never back: an
|
||||
@@ -55,6 +69,14 @@ and reinstalls (`bin/gstack-capy-install` on a Capy install, else `./setup
|
||||
[UPGRADE_REFUSED](troubleshooting.md#upgrade-refused) and never stashes or
|
||||
resets.
|
||||
|
||||
Several agents sharing one checkout (eight Capy subagents on a machine) hold
|
||||
workflow leases on it: every `bin/gstack-skill-start` touches
|
||||
`<git dir>/gstack-leases/<harness pid>` (`bin/gstack-lease touch`; pruned after
|
||||
120 minutes). `bin/gstack-upgrade`, and so the session-start self-upgrade,
|
||||
refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy) while a lease
|
||||
whose pid is alive and outside its own process chain exists;
|
||||
`bin/gstack-lease list` shows them and `GSTACK_UPGRADE_FORCE=1` overrides.
|
||||
|
||||
## What the installer does
|
||||
|
||||
1. Reuses the checkout it lives in (or clones into `--root <dir>`), under the
|
||||
@@ -194,7 +216,7 @@ git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.lo
|
||||
|
||||
and snapshots capture `$HOME` (the checkout, `~/.bun/bin`, both host
|
||||
profiles). Each session then pays only the `--check`. A baked revision still
|
||||
moves forward at session start when it falls below the supported minimum, and
|
||||
moves forward at install-mode session start whenever it is behind origin, and
|
||||
the baked `~/.bun/bin` reaches workflows through the `GSTACK_LAUNCH` line. The
|
||||
project Setup is shared by every thread in the project, so changing it is the
|
||||
project's decision.
|
||||
@@ -203,8 +225,10 @@ project's decision.
|
||||
|
||||
`capy/support.env` holds `GSTACK_MIN_REVISION` and `GSTACK_CAPY_MIN_WRAPPER`.
|
||||
Raise `GSTACK_MIN_REVISION` only for install-honesty or security fixes, at
|
||||
stamp time, to the VERSION being released: every machine below it is
|
||||
fast-forwarded at its next session start. It never exceeds VERSION
|
||||
stamp time, to the VERSION being released: it is the floor below which the
|
||||
trailer fails and the bootstrapper itself fast-forwards (a checkout above it
|
||||
that is behind origin is fast-forwarded by the checkout's own wrapper, so the
|
||||
floor is not what keeps machines current). It never exceeds VERSION
|
||||
(test/capy-skill-wrapper.test.ts). Raise `GSTACK_CAPY_MIN_WRAPPER` together with
|
||||
`GSTACK_CAPY_WRAPPER` in the bootstrapper when the bootstrapper itself
|
||||
changes; older Drive copies keep working and print an owner action
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# gstack fix wave (October 12, 2026): install honesty, unattended autoplan on Capy, plan-to-gate, queue-aware shipping
|
||||
|
||||
## URGENT, outside this plan
|
||||
|
||||
- `fixwave-run-20261012204516-ceo-native-6` [High]: D16 scans outgoing diffs; sealed eval transcripts and a quoted sealed answer are **already committed** in gbrain's public repository, and nothing in this wave rewrites history. Owner: the gbrain repo owner (Garry). Options: scrub history and force-push the affected refs, or accept the disclosure in writing. This wave only stops the next one.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### Context
|
||||
|
||||
Base: origin/main `70fab3e` (v1.91.94.0). Input: field evidence from the GBRA threads, which ran gstack on Capy cloud machines during gbrain fix waves 15–16 (2026-10-10..12, gstack 1.91.22–1.91.94), plus reproductions on this machine (a Capy cloud box, Ubuntu 24.04, Bun 1.3.14 at `/usr/local/bin/bun`, no Codex CLI harness). Plan revision 3: revision 1 was reviewed by six voices (CEO, DX, Eng; Claude and GPT each), every gate item was answered `b` (take the reviewers' change), revision 2 folded those in and was reviewed again by six voices; this revision folds in the revision-2 findings (eight gate items plus the in-item fixes). The Review record lists what each phase accepted at each revision.
|
||||
|
||||
Reproduced here before writing a line (2026-10-12, this machine):
|
||||
|
||||
- **A1/A2.** `~/.local/share/gstack` sat at 1.91.69.0, 25 releases behind. The drive skill's `install.sh --check` printed `gstack: fail installer — checkout … predates gstack-doctor --check (installed 1.91.69.0); fix: … or run /home/user/.local/share/gstack/bin/gstack-upgrade to move it` and exited 1. `bin/gstack-upgrade` does not exist at any revision (`git ls-tree origin/main bin/ | grep -c gstack-upgrade` → 0); `gstack-upgrade/` is a Claude-only skill directory (`lib/doctor-components.ts:117`, `CLAUDE_ONLY`). The doctor's own fix text names it too (`bin/gstack-doctor-check.sh:338`: `…, or run /gstack-upgrade`). Rerunning the installer with a sidecar root installed 1.91.94.0 in 32 s `[measured: this machine 2026-10-12]` but ended `gstack: fail claude`, because `./setup` left `~/.claude/skills/gstack` pointing at the stale checkout. Only `git merge --ff-only origin/main` in the stale checkout plus `./setup` recovered the machine, exactly the field report.
|
||||
- **A3.** After that recovery the installer printed `gstack: ok 1.91.94.0 for=autoplan` and exited 0. The very next `install.sh --check` in a fresh shell printed `FAIL runtime — bun 1.3.14 at /usr/local/bin/bun is below the supported minimum 1.4.2` and `FAIL pins`, trailer `gstack: fail runtime,pins`, exit 1; with `PATH=$HOME/.bun/bin:$PATH` the same check printed `gstack: ok`. The installer exports the verified Bun only inside its own shell (`bin/gstack-capy-install:108`) and the `GSTACK_LAUNCH` line it prints (`:145`) carries no `PATH`. The runtime FAIL row's `fix:` says to reinstall Bun although `~/.bun/bin/bun` is already 1.4.2 (`lib/result-codes.ts:126`). `lib/autoplan-prompts.ts:33` and `lib/restamp.ts:177,314` spawn `bun` by PATH name.
|
||||
- **B5.** `lib/autoplan-gate.ts:107-120` `finishGate` writes decision rows whose ids come from `lib/gate-list.ts:148-150` as `${list.run}-${item.id}`; `gate_rev` is a separate column, so a second gate revision re-emits the same ids and `gstack-artifact validate` reports `ARTIFACT_DUPLICATE_ID`. (`finishGate` already retains earlier revisions' rows; the ids are what collide.) Reproduced by this run itself: after gate rev 2, `gstack-artifact validate run.json` reports 9 `ARTIFACT_DUPLICATE_ID` errors (`d1`..`d8`, `p1` at `decisions.jsonl:19-27`), so the run cannot validate until B5 ships.
|
||||
- **B6/B8.** `bin/gstack-autoplan --help`: no `revise` subcommand. Reopened phases snapshot the run's `plan-active.md` (`lib/autoplan-run.ts:119`), so a reopen does review a revised plan when that file is edited; the field reruns reviewed the old plan because the source document was edited instead, and nothing told the agent which file the run reads. The snapshot tool (`bin/gstack-autoplan-snapshot.ts:90`) requires exactly one `## Implementation plan` followed by one `## Review record` (`lib/autoplan-export.ts:54-57` is tolerant); `init` already scaffolds a heading-less plan (`:415`), and the error names neither the missing heading nor a fix. After a `b` answer, `answer` prints `NEXT: next` (`bin/gstack-autoplan:184`) and `next` snapshots and dispatches before anyone is told to revise, which is how the field reruns reviewed the old plan.
|
||||
- **B9.** `lib/autoplan-gate.ts:81-83`: every user-challenge item is built with `recommended: 'a'` = "keep your direction" even when both voices recommend the change. Live example: this plan's own gate rev 1 rendered 17 items as "keep your direction (recommended)" while the reviewers argued the opposite on every one. `lib/gate-list.ts:138` resolves `all` to `recommended`, and `answer` reopens on `how === 'override'`, not on the option chosen.
|
||||
- **B10.** `lib/autoplan-prompts.ts:23-27`: UI scope is two or more hits of `component|screen|form|button|modal|layout|dashboard|sidebar|nav|dialog` anywhere in the plan text, Review record included; `detectScope` (`:39-46`) records `source: 'flags'` and nothing persists flags across runs. `openPhase` passes prior consensus only to Eng (`lib/autoplan-run.ts:121`), so DX never sees the CEO consensus (this run: both rev-2 DX prompts contained zero consensus blocks). `bin/gstack-autoplan` never passes through `gstack-skill-start` or the doctor; this run's `run.json` carries no `gstack_revision`. Gate answers record `chosen`, `answered_at` and `reply` but not who answered (`lib/gate-list.ts:155`).
|
||||
- **D15/D16.** `lib/ship-policy.ts:34,58` has `release_tool` (one command run inside `gstack-restamp`'s staging worktree after the stamp) but no way for a repo whose own script is what CI checks to own the whole stamp; `bin/gstack-pregate --help` has no stamp or sealed check.
|
||||
|
||||
Items not reproducible on this machine are taken from the GBRA threads as stated and marked `[field]`: A4 (52 s install on automation machines), B7 (gate churn across three revisions), B11 (rules claimed "written first", unreceipted numbers), C13 (lanes passing `bun run verify` but missing manifest rows, pinned contracts, stale goldens), C14 ($723 unpriced gate, hidden synopsis builds), D15 (gbrain's `release-restamp.ts`, `since:` rows, migration v240 collision), D16 (transcripts and a quoted sealed answer pushed to a public repo).
|
||||
|
||||
**What already exists (reused, not rebuilt).** `bin/gstack-session-update:270-300` upgrades the checkout in-session: receipted fetch, `gstack_bun_incoming_hold` (`bin/gstack-bun-version.sh:82`), hook hold, `merge --ff-only --autostash` (a bare `--ff-only` wedged permanently on patched tracked files, #2566), setup, migrations. `gstack-doctor --check --revision <ref>` and `REVISION_UNMET` exist (`bin/gstack-doctor:55`, `bin/gstack-doctor-check.sh:338`). The installer already exits non-zero on every skipped-Bun path. `gstack-autoplan submit --result` binds a host-subagent markdown result by receipt and fence (this plan's own run closed all six attempts through it). `lib/tree-receipt.ts` (`buildReceipt`, `classifyFile:69`) already classifies stamp-only hunks, `since:` rows, changelog headings and gate reuse, printed by `bin/gstack-tree-receipt`. `scripts/test-paid-shards.ts --list` prints a free paid-gate plan (human-readable; no `--json`). `docs/unattended.md:135-243` is the Capy driver loop. `lib/claude-code-migration.ts:159` spawns through `process.execPath`. `bin/gstack-redact-prepush:604` already parses pre-push stdin refs for the managed pre-push wrapper (`bin/gstack-redact`), single-guard, spawning `bun` by name. `test/lane-coordination.test.ts:117` is the byte-pin pattern. `setup:309-327` resolves Bun with `command -v bun` and accepts 1.3.14 as merely untested.
|
||||
|
||||
What worked and must not regress (each gets a pinned test if it lacks one): `install.sh --check` FAIL rows with exact `fix:` lines; `gstack-autoplan next` phase order plus the snapshot hash on every attempt; accepted-obligation blocks that replace earlier ones; exact-token gate answers with `REOPENED:` reruns; `PLAN_REALITY … unreceipted=` flags; the pluggable outside voice on another model family.
|
||||
|
||||
Repo constraints: SKILL.md is generated from `.tmpl`; new logic in `lib/*.ts` behind thin `bin/` entrypoints, every module registered in `test/fixtures/module-size-ratchet.json` (800 lines, 150 per function); state paths through `lib/state-root.ts`; result codes appended to `lib/result-codes.ts` with `docs/troubleshooting.md` anchors; every new outbound call wrapped in `_receipted_git` / `lib/egress-receipt.ts` (`test/egress-receipt-wiring.test.ts` fails on an unreceipted sink); ship is on `stamp-at-merge` with `bump: patch`, so every PR opens with a tentative base+PATCH stamp and the restamp runs when it is next. Zero-headroom skills: autoplan union 1.149, review 1.296, ship 1.645, qa ~9 B; prose added there is replaced byte for byte or printed by a bin.
|
||||
|
||||
### Release promises (the finish line)
|
||||
|
||||
- **P1. A stale machine cannot run a workflow silently.** At session start, when the receipted fetch shows `origin/main` moved, the checkout fast-forwards itself (with autostash, never a reset) and refreshes setup, reruns `--check`, and refuses to run a workflow while the trailer is `fail`; below the contract floor it refuses even offline, above it an offline machine runs with a visible stale note. The Capy autoplan driver itself runs the doctor on its first `next` and refuses a `fail` trailer. A 1.91.12 checkout reaches current from one `install.sh --check`, on every machine a thread uses, and a subagent's first report carries its `gstack:` trailer.
|
||||
- **P2. The installer and the check agree.** `gstack-capy-install` never prints `gstack: ok` on a machine where the next fresh-shell `--check` fails; `GSTACK_LAUNCH` carries the verified Bun on `PATH`; lib code never spawns `bun` by name.
|
||||
- **P3. A three-revision unattended autoplan run validates and closes.** Rev-scoped decision ids, `submit --result --extract` and `--outcome` for phases run as Capy subagents, `revise --plan v2` in the same run before any rerun can dispatch, reruns that dedupe only explicitly linked repeats, user-challenge defaults that follow mechanically agreeing voices without letting `all` flip the owner's direction, answers with provenance, scope from changed files, every phase fed its predecessors' consensus with transitive invalidation, and a compact Capy driver doc.
|
||||
- **P4. Plan obligations reach the mechanical gates before a lane reports done,** and every paid gate a plan names is priced from a declared adapter's structured plan before approval; a gate without an adapter is an owner-answerable `GATE_UNPRICED` item, never silently accepted. Release one ships the gstack adapter only; other runners' manifests report `not configured`.
|
||||
- **P5. Stamp-at-merge delegates to the repo's own script where that is what CI checks,** with the stamp-only proof coming from the tree receipt, migration-collision detection, and a sealed-output guard that runs before push and inside the pre-push hook.
|
||||
|
||||
### Delivery shape
|
||||
|
||||
Three PRs in a dependency chain, not four independent lanes (gate rev 1, d1/d4): every lane appends to `lib/result-codes.ts`, `docs/troubleshooting.md`, `test/fixtures/module-size-ratchet.json` and `scripts/free-test-durations.json`; A and B share `lib/coordinator-contract.ts` and `lib/headless-artifacts.ts`; C13, D15 and D16 share `bin/gstack-pregate` and `lib/pregate/`. Each PR opens with a tentative base+PATCH stamp, is registered with the merge queue before it opens, merges `origin/main` in (never rebases) when the predecessor lands, and ships with a failing test first on the simulated Capy host.
|
||||
|
||||
| PR | Items | Base | Owns (no other PR edits these) |
|
||||
|---|---|---|---|
|
||||
| A | A1–A4, host simulator | main | `bin/gstack-upgrade`, `bin/gstack-upgrade-lib.sh`, `bin/gstack-session-update` pull stage, `bin/gstack-capy-install`, `bin/gstack-doctor-check.sh`, `scripts/capy/**`, `test/helpers/capy-host-sim.ts` |
|
||||
| B | B5–B12, the trailer rule in `lib/coordinator-contract.ts` and `lib/headless-artifacts.ts` | A's branch | `lib/autoplan-*.ts`, `lib/gate-list.ts`, `lib/plan-reality.ts`, `bin/gstack-autoplan*`, `autoplan/sections/**`, `docs/unattended.md` |
|
||||
| C | C13, C14, D15, D16 (one pregate PR) | A's branch (merged after B) | `bin/gstack-pregate`, `lib/pregate/**`, `lib/ship-policy.ts`, `lib/restamp.ts`, `lib/tree-receipt.ts`, `bin/gstack-eval-plan`, `scripts/test-paid-shards.ts` (`--list --json`), `bin/gstack-redact*`, `lib/prepush-refs.ts`, `docs/pregate.md`; **after B lands**: `lib/plan-reality.ts` (GATE_PRICE clause), `lib/headless-artifacts.ts:41,145` (tasks v3), `lib/autoplan-export.ts:31` (checks), `lib/coordinator-contract.ts:22` (lane-done and sealed sentences) |
|
||||
|
||||
Stacked stamps: each successor's tentative stamp is computed from `origin/main`, not the predecessor's branch; when a successor merges `origin/main` after its predecessor squash-lands, it takes main's `VERSION` and changelog heading and runs `gstack-restamp --next --base main` to re-mint rather than hand-resolving; `classifyFile` treats a merge commit whose second parent is already-gated `origin/main` as `gate-reuse: eligible`, so the chain costs B one regate and C two at most (test: a restamp fixture with a squash-merged predecessor resolves without a manual conflict and prints `STAMP_ONLY: yes`). Shared append-only files get pre-assigned blocks so merges are mechanical: result codes and troubleshooting anchors are listed per PR below; each PR adds its own module-size-ratchet rows and durations rows under its own file names; CHANGELOG sections stay separate.
|
||||
|
||||
**Result codes, per PR** (each with a `docs/troubleshooting.md` anchor before any line prints; every failure line ends `(<CODE>; <anchor>)` like the doctor rows). A: `UPGRADE_NOT_FAST_FORWARD`, `UPGRADE_BUSY`, `UPGRADE_FETCH_FAILED`, `UPGRADE_PINNED_ROOT`, `UPGRADE_TARGET_UNAVAILABLE`, `GSTACK_STALE_REFUSED`, `RUNTIME_OFF_PATH`. B: `PLAN_SHAPE_PARTIAL`, `PLAN_SHAPE_AMBIGUOUS`, `RESULT_FINDINGS_MISSING`, `REVISE_OBLIGATIONS_DROPPED`, `REVISE_ATTEMPTS_OPEN`, `REVISE_SCOPE_CHANGED`, `PLAN_UNCHANGED_SINCE_GATE`, `PLAN_REALITY_INCOMPLETE`. C: `MANIFEST_ROW_MISSING`, `GATE_OVER_CAP`, `GATE_UNPRICED`, `STAMP_NOT_ONLY`, `STAMP_EVIDENCE_MISSING`, `MIGRATION_COLLISION`, `MIGRATION_CENSUS_INCOMPLETE`, `SEALED_CONTENT_REFUSED`, `SEALED_RESULT_MISSING`.
|
||||
|
||||
**The simulated Capy host** (`test/helpers/capy-host-sim.ts`, lands in PR A, used by every PR). A scratch root with `usr/local/bin/bun` as a shim that reports `1.3.14` (and otherwise execs the real Bun), no `codex` on PATH, isolated `HOME`, `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_ALLOW_PROTOCOL=file`, `http_proxy=http://127.0.0.1:9`. Subprocess environments are built from an allowlist (never inherited), every state/install/runtime/temp root is set explicitly, credentials are stripped, and a test plants sentinel files outside the root and asserts they are unchanged after install, upgrade and cleanup, including through an escaping symlink. The stale checkout is **synthesised**, because this checkout, CI's (`.github/workflows/quality-gate.yml:40`) and the installer's own clone (`bin/gstack-capy-install:65`) are all shallow and tagless: a local bare origin with two commits (stale = minimal tree with `VERSION 1.91.12` and no `bin/gstack-doctor-check.sh`; current = the install fixture's source files), cloned `--depth 1 --single-branch` like the installer; divergence is one extra local commit. One Docker smoke (`.github/docker/Dockerfile.capy-sim`, Ubuntu 24.04 + Bun 1.3.14 at `/usr/local/bin`) runs the same scenarios in a `workflow_dispatch` lane; it is a `requires-remote` obligation for PR A in the ship receipt, its evidence label declared outside `stamp_sensitive_gates` (`lib/ship-policy.ts:48`) so the tree receipt's `gate-reuse: eligible` carries it across a stamp-only restamp; only a successful job receipt bound to the gated SHA, container digest and selection clears it (a green unit-only rerun cannot erase it). Sim fixtures that run the real `./setup` cost about 30 s each `[estimated]`, so the suite shares one upgraded checkout per file where the scenario allows.
|
||||
|
||||
### PR A: install and upgrade honesty
|
||||
|
||||
**A1. `bin/gstack-upgrade` exists and is the one upgrade path.** Bash, not Bun (it must run on a checkout whose Bun it is about to verify). `bin/gstack-session-update`'s pull stage is extracted into a sourced `bin/gstack-upgrade-lib.sh` (receipted fetch labelled `gstack-upgrade-fetch`, `gstack_bun_incoming_hold`, hook hold, `merge --ff-only --autostash`, setup, migrations, pending-stage file) and both callers use it. Locking protects the **installation**, not a state root: workflow leases live under the installation (`<install>/.gstack-leases/<pid>`, written by `gstack-skill-start` and `gstack-autoplan next`, so eight subagents with distinct state roots are all visible); `gstack-upgrade` takes the installation flock (the installer's `.gstack-capy-install.lock`), refuses `UPGRADE_BUSY` (exit 3) while a lease whose pid is alive and outside the caller's process ancestry exists, and after acquiring the lock re-reads HEAD against `origin/main` so a second concurrent caller prints `UPGRADE: none (current)`. It refuses only local commits or a non-ancestor upstream with `UPGRADE_NOT_FAST_FORWARD` and the exact `git` commands; on an autostash conflict it keeps the stash and the original bytes and refuses rather than dropping anything (test: genuine user edits conflict → bytes and stash survive; a generated-overlay edit fast-forwards). `--to <tag|sha>` distinguishes a tag (`git fetch origin tag <t>`) from a commit (`git fetch origin <sha>`), verifies the commit (`UPGRADE_TARGET_UNAVAILABLE` otherwise), applies the same fast-forward rule, and records the pin in the state root; a `CLAUDE_PLUGIN_DATA`-managed root prints `claude plugin update gstack` and exits 3 (`UPGRADE_PINNED_ROOT`) without touching the tree. Prints `UPGRADE: <old> -> <new> ff=yes migrations=<n> setup=ok` then the doctor trailer, all on stdout. The doctor's `REVISION_UNMET` fix text names this bin; the `gstack-upgrade` Claude skill's Step 4/4.75 delegates to it and its `git reset --hard` divergence fallback is removed (listed under `## Behavior changes` in PR A; the fallback was reachable only when local commits existed, which the rule forbids). Decision rule, written first and stated here: the upgrade path never discards local commits, dirty files or a stash; when it cannot fast-forward it prints the exact commands and stops. Tests: `upgrade-refuses-divergence-keeps-bytes` (including diverged-with-clean-tree, two concurrent `--check` calls on one stale sim producing exactly one `UPGRADE:` line and two `gstack: ok` trailers, no upgrade while a live lease exists, and a dead-pid lease not blocking).
|
||||
|
||||
**A2. The authority on "stale" lives outside the checkout; the first hop has an owner; the drive carries no logic.** The drive skill's `install.sh` becomes a ~20-line **bootstrap** with no version number, bin name or flag in it (pinned by a test over the in-repo copy `scripts/capy/bootstrap.sh`): clone if absent; `git fetch origin main` wrapped in `_receipted_git` when the checkout's `bin/gstack-egress-lib.sh` exists, else a plain fetch printing `receipt: unavailable (pre-upgrade bootstrap)` on stdout (the one exemption named in `test/egress-receipt-wiring.test.ts`); then `git show origin/main:scripts/capy/install.sh > $tmp && bash $tmp --check "$@"`, falling back to the checkout's own `scripts/capy/install.sh` only when the fetch fails. All logic is in-repo in `scripts/capy/install.sh`, tested in the sim; the drive `SKILL.md` is generated from `scripts/capy/SKILL.md.tmpl` by `gen:skill-docs`, listed as a release output, with a test that every `bin/<name>` and flag the skill names exists. The in-repo wrapper's `--check` sources the fetched tree's `bin/gstack-bun-version.sh` and, when `$BUN_INSTALL/bin/bun` reports `gstack_bun_status ok`, prepends its directory to PATH **before** the merge, `./setup --refresh-registered` and the exec of `bin/gstack-doctor --check` (otherwise the upgrade ends `fail runtime` under the 1.3.14 Bun first on PATH; `setup:309` resolves Bun with `command -v bun`). It then applies one ordered policy, stated here and in the wrapper: (1) below `CONTRACT_FLOOR_REVISION` refuse, online or offline (`GSTACK_STALE_REFUSED`, exit 3, trailer `fail revision`); (2) a recorded `--to` pin skips with `SELF_UPGRADE: skipped (pinned <ref>)` and the stale note; (3) the off switch (`GSTACK_SELF_UPGRADE=off` or `auto_upgrade=false` via the checkout's `gstack-config`, `bin/gstack-session-update:153`) skips with `SELF_UPGRADE: disabled (<source>)`; (4) otherwise fetch under a deadline (`--fetch-timeout`, default 20 s; a timeout prints `UPGRADE_FETCH_FAILED (timeout <s>)`, distinct from a connection refusal) and, when `origin/main` moved, fast-forward and refresh setup unconditionally through `bin/gstack-upgrade` (G8 option (c): always current when online, so there is no version zoo and no knob to forget; the cost is setup time at session start on release days, bounded because A4 defers the full install to first workflow use); when the fetch fails above the floor, run with `gstack: ok <rev> (stale; upgrade unavailable: UPGRADE_FETCH_FAILED)`. `CONTRACT_FLOOR_REVISION` lives in `lib/doctor-components.ts`, is read by bash from the generated `bin/gstack-doctor-components.sh`, and is bumped deliberately when the CLI or artifact contract changes. Every trailer and `UPGRADE:`/`SELF_UPGRADE:` line goes to stdout; a test pins the wrapper's result-code anchors against `lib/result-codes.ts` as `bin/gstack-capy-install:50` does. In `gstack-skill-start` the upgrade is a mechanical pre-step printing `SELF_UPGRADE: none|done <old>-><new>|skipped …|disabled …|refused <code>`, and the unattended sentence at `:616` becomes "the tool already applied any required upgrade; never run an upgrade yourself". `gstack-skill-start` prints `GSTACK_TRAILER: gstack: ok <rev>` as the first line of every workflow so a subagent's first report carries it; `lib/coordinator-contract.ts` gains the sentence and the handoff-format order gains the trailer, and `gstack-artifact validate` requires the trailer line in an unattended `review-record.md` only when `run.json.gstack_revision` is at or above this release (reference fixtures keep validating). Session-check latency: the in-checkout check measured 0.38 s `[measured: this machine 2026-10-12]`; the fetch adds network time bounded by the deadline. Fixtures: `install-stale-1-91-12-self-upgrades` (synthesised stale origin → current from one bootstrap `--check`, runtime row PASS and trailer `gstack: ok` with the 1.3.14 shim first on the parent PATH and a supported Bun planted in `$BUN_INSTALL`); the 2×2×2×2 matrix floor × pinned × switch × offline, each cell asserting the `SELF_UPGRADE:` line and the trailer, the hanging-fetch cell using a listener that accepts and never answers; a diverged checkout refuses with the commands and an untouched tree.
|
||||
|
||||
**A3. One Bun, agreed on by installer, check and launch.** The runtime row stays honest to `command -v bun` (gate rev 1, d2/d6). When `$BUN_INSTALL/bin/bun` is already the supported version (read from `bin/gstack-bun-version.sh`, never hardcoded) and only PATH is wrong, the row reads `verified bun <v> at ~/.bun/bin/bun is not on PATH` with fix `source <state root>/launch.env before each gstack command (or prepend the GSTACK_LAUNCH line)` (`RUNTIME_OFF_PATH`; `RUNTIME_BELOW_MINIMUM.fix` gains the PATH clause). `gstack-doctor --check` writes `<state root>/launch.env` (plain `KEY=value` lines, no shell syntax, `PATH_PREPEND=<abs dir>`) and prints `GSTACK_LAUNCH_FILE: <path>` plus the display line `GSTACK_LAUNCH: PATH="<dir>:$PATH" GSTACK_SESSION_KIND=…` with every literal shell-quoted; `--json` carries `path_prepend` and the assignments as a map (paths with spaces, quotes and `$` round-trip; nothing is `eval`ed and the file is parsed, not sourced). The installer's final `--check` runs under `env -i PATH=<prepend>:<system PATH> HOME=<home>` plus the launch assignments, so `ok` means the next fresh shell says `ok`; a host with only 1.3.14 and no network ends `gstack: fail runtime` from both (the installer's existing non-zero skip exits are pinned by this negative test, not re-implemented). `lib/autoplan-prompts.ts:33` and `lib/restamp.ts:177,314` spawn `process.execPath`; `test/bun-spawn-ratchet.test.ts` greps `lib/**/*.ts`, `bin/*.ts` and generated hook templates for a bare `bun` spawn with a reasoned exemption list. Fixture: `install-ok-then-check-ok` records `Bun.version` from the snapshot tool inside the sim and asserts the supported version with the 1.3.14 shim first on the parent's PATH.
|
||||
|
||||
**A4. No 52 s install on machines that never run a workflow.** `[field]` The bootstrap's session-start step is `--check` only; the install runs at first workflow use from the wrapper (`install.sh --for <skill>`), printing `INSTALL: lazy (first workflow use) <seconds>s`. `docs/capy-install.md` gains "Baking gstack into a Capy snapshot" (what the Setup runs, what `--check` must print after boot, why `~/.bun/bin` must be on the Setup's PATH) and states the relation between the self-upgrade and `auto_upgrade`. Tier 2: `gstack-capy-install --prefetch`.
|
||||
|
||||
### PR B: unattended `/autoplan` on Capy
|
||||
|
||||
**B5. Rev-scoped decision ids; attempt-qualified references.** `lib/gate-list.ts:148` emits `${run}-g<gate_rev>-<item>` through one revision-aware reference constructor shared by decisions, `tasks.jsonl` `blocked_by` links and `applyReply` (which today derives the item with `row.id.slice(run.length + 1)`); both id shapes parse; `label` stays the primary key. Finding ids keep their shape (`lib/headless-artifacts.ts:127`), every canonical finding row and `tasks.findings` ref carries `attempt` (`${run}-a<n>`), and every cross-revision reference (`superseded_by`, the `DEDUPED:` line, the reopen journal) is attempt-qualified `<attempt>:<id>`; `gstack-artifact validate` checks each referenced attempt exists in the journal. Fixture: `autoplan-3-rev-validates` with v1 and v2 rows in one `decisions.jsonl`, a rev-2 finding reusing a rev-1 id, actionable findings, tasks, a partial answer and a stale-answer replay; zero `ARTIFACT_DUPLICATE_ID`.
|
||||
|
||||
**B6. `submit --result` closes a run whose phases ran as Capy subagents; failures have a terminal outcome.** No `--record` and no `--attempt new` (gate rev 1 d7/d12; gate rev 2 d5: `next` already mints the attempt and reserves against the cap at `lib/autoplan-run.ts:168-172`, and this run closed twelve attempts through `next`/`submit`). `submit --result` gains `--extract` with the parsed grammar `### F<n> [<Severity>] <title> — <file>:<line>`; a heading without an anchor refuses `RESULT_FINDINGS_MISSING` naming it and printing the example, or with `--anchor-optional` the row carries `anchor: none` and `DEDUPED:` prints `by=title`. `submit --outcome failed|unavailable --attempt <id>` is the terminal-outcome command for a host subagent that never produced a result (`markOutside` is reachable only from `run` today, `bin/gstack-autoplan:141`); terminal transitions are centralised across `submit`, `next` and `resume`: ordinary `next` never redispatches failed or unavailable work, usage settles once, a native failure leaves the phase `incomplete`, and outside-only degradation stays explicit. Every `--json` path returns one versioned JSON object, refusals included. The recovery triad (`resume` → `submit --attempt <id>`; `next --redispatch <attempt>`; `submit --outcome`) is one table in the driver doc. Fixture: each failure before and after its sibling result, asserting stable attempts, reservations and coverage under repeated `next`/`resume`.
|
||||
|
||||
**B7. Reruns dedupe only explicit repeats; nothing is suppressed silently.** `[field]` A reopened phase's prompt on `gate_rev > 1` carries the previous gate's answered items with their attempt-qualified ids. `lib/autoplan-gate.ts` dedupes a rerun challenge only when the finding links a prior item explicitly (`supersedes: <attempt>:<id>`) and its issue and resolution are compatible with that item's and the consumed inputs are unchanged; location and title similarity only **propose** a match (`DEDUPE_CANDIDATE:` line), never apply one. Packaging and other reversible choices render as `kind: auto` items with `basis: packaging` (visible, defaultable, never reopening a phase) rather than dropped (gate rev 1, d8). Fixture: a rerun whose only new challenge is packaging produces one auto item and no reopen; a paraphrased repeat with a valid prior-item link is deduped; two distinct defects at one answered line are both kept.
|
||||
|
||||
**B8. `revise --plan <v2>` in the same run, before any rerun can dispatch; transitive invalidation.** After a content-changing answer, `answer` prints `REVISE: gstack-autoplan revise --out <dir> --plan <v2> (before next)` and `EDIT: <plan-active path>` ahead of `NEXT:`, exposes the same next action in `--json`, and `next` refuses `PLAN_UNCHANGED_SINCE_GATE` (exit 3) when the Implementation-plan hash equals the one snapshotted at the answered gate revision unless `--same-plan` is passed and journaled as a deviation (`bin/gstack-autoplan:184`). `revise` replaces only the Implementation plan bytes of `plan-active.md`, refuses a v2 carrying `## Review record` or an empty body (`PLAN_SHAPE_AMBIGUOUS`), refuses while any attempt is `dispatched`/`execution_unknown` (`REVISE_ATTEMPTS_OPEN`), runs the snapshot tool's obligation check and refuses `REVISE_OBLIGATIONS_DROPPED` with the block names unless `--drop-accepted <ids>`, re-runs `detectScope` over the new bytes and prints `SCOPE: ui <old>-><new> (source=…)`, refusing `REVISE_SCOPE_CHANGED` when the phase order would change unless `--phase-order <list>` is passed (the added phase opens before Eng with every closed predecessor's consensus; `required_phases` is rewritten and journaled), then performs a journaled generation transition: `plan_revisions[]` in `run.json` (generation, both hashes, dropped ids, time), hash-named immutable archives of bound files, `restore.r<rev>.md`, an atomic manifest write, and idempotent `resume` across a crash after any step (one `failAfter` test, as `lib/restamp.ts` does). Every phase records the identities of its consumed inputs (plan snapshot hash plus each predecessor consensus hash); `revise`, `answer` and a changed consensus invalidate the transitive consumers of what changed (a CEO change reopens DX, design and Eng, not only Eng), recompute the phase order, and invalidate the previous gate approval; the reopen line states the dependent reruns and their estimate (gate rev 2, d6). The `PLAN_SHAPE` error at `bin/gstack-autoplan-snapshot.ts:90` gets result code `PLAN_SHAPE_PARTIAL` naming the missing or duplicated heading and the one-line fix; no `scaffold` subcommand (init already scaffolds). Fixtures: the 3-rev run revises with a v2 containing a Review record (refused), with a clean v2 (asserting `review-record.md` still carries rev-1 consensus blocks), with an early `next` before `revise` (refused, zero attempts dispatched); a four-phase CEO-change regression, an Eng-only control, and a CLI-to-TSX revision that inserts design.
|
||||
|
||||
**B9. User-challenge defaults follow mechanically agreeing voices; `all` never flips the owner's direction; answers compose and carry provenance.** In `buildGate` (`lib/autoplan-gate.ts:81-83`) the agreement rule is mechanical: a `confirmed` reconciliation row whose two findings are both `user_challenge` **and** whose resolutions share token overlap at or above the reconciler's half-words heuristic (`lib/autoplan-reconcile.ts:116`) renders `recommended: b, basis: both voices agree` with the reconciled change in the option text; a `confirmed` row with differing resolutions renders `recommended: a, basis: both challenge; resolutions differ` with both resolutions printed; one voice → `recommended: a, basis: one voice; the plan as reviewed stands`; an `agreement: true|false` field on the ReconciliationOverride schema (`:130`) overrides the heuristic. Items recommended `b` are excluded from `all` (`answerable_by_all: false` in `gate.json`) and stay `gate_pending` until answered by token; `GATE_PENDING` prints the exact tokens per pending item and `all does not answer items recommended b` (`:141`); reopen keys on the chosen option changing reviewed content, not on `how`. Incremental answers compose: persisted choices for the current revision merge with new explicit tokens, only unanswered eligible items default, and pending approval plus the reopen set derive from the complete effective decision set, reopening once (`uc1b` then `p1b`; `all` then individual tokens). `answer --by <human|agent:<id>>` is required in unattended runs; `answered_by` lands on every DecisionRow and in the review-record gate line, and `gstack-owner-brief` flags a revision whose user-challenge `b` answers were all agent-given as `agent-accepted; owner has not decided`. Fixture: `gate-defaults-follow-agreement` (the 15-item field gate; opposite fixes at one location → `a` with the basis line; a paraphrased same fix → `b`; `all` leaves agreed items pending; `d1b` reopens; composed answers reopen once).
|
||||
|
||||
**B10. Scope from changed files and rendered surfaces, flags sticky, every phase informed.** `Scope.ui` becomes `yes|no|unknown` (`lib/autoplan-prompts.ts:47` `phaseOrder` and the run-log rendering follow). `detectScope` (`:39`) runs over the Implementation plan bytes only: `ui=yes` when the plan names files under rendered-surface globs (default `**/*.tsx`, `**/*.vue`, `**/*.svelte`, `**/*.css`, `**/views/**`; `**/templates/**` is not a default and gstack ships its own `.gstack/autoplan.json`, with `init`/schema, excluding `qa/templates` and friends) or a fenced `surface:` block lists a screen; `ui=unknown` when no paths match but two or more prose UI terms appear (design runs light, never skipped silently); one prose hit is a `hint:` line and nothing more. Flags persist in `run.json.scope.flags` with provenance and apply to reopened phases (`source=flags (sticky from rev 1)`); `revise` may replace or reset them. `openPhase` passes every closed predecessor's consensus to DX and design, not only to Eng (`lib/autoplan-run.ts:121`), and refuses `CONSENSUS_MISSING` for a required predecessor. Fixtures: a plan with "doctor", "status", "description" and a Review record containing "dashboard" twice → `ui=no`; `qa/templates/x.md` → `ui=no`; `app/components/Foo.tsx` → `ui=yes`; prose-only "modal" and "sidebar" → `ui=unknown`; the DX prompt file contains the CEO consensus block.
|
||||
|
||||
**B11. PLAN_REALITY requires the rule text and receipts for decision-driving figures, before dispatch.** `lib/plan-reality.ts`: the `binding decisions` row fails when a plan claims a rule was written first but no rule text appears in the Implementation plan. The preserved `unreceipted=<row ids>` metric is unchanged; a new `figures_unreceipted=<n>` (with the first three excerpts and lines) scopes to unit-bearing figures (`$`, s/ms/min, %, KB/MB, calls, lines) inside the Implementation plan, accepts the existing tags `[measured: <src>]` / `[estimated]` / `[re-measure before gate]` (`lib/plan-reality.ts:67`) plus `[target]` and `[fixture]` for caps and fixture parameters, requires a receipt to be a `file:line` match on the same line (a bare sha is not a receipt), and ignores versions, exit codes, item ids and `g<n>`/`a<n>`/`uc<n>` tokens. The plan-bytes scan runs at `init` and the first `next` as a pre-dispatch refusal (`PLAN_REALITY_INCOMPLETE`, exit 3) with `--allow-unreceipted` recorded as a deviation; review-text rows still run at phase close as a persisted, input-bound validation boundary, without redispatching completed reviewers. Fixture: the scanner over this plan's Implementation plan pins an enumerated unreceipted set (zero once caps are tagged); the $723 arm list fails, `$723` with a sha on the line still fails; `32 s [measured: this machine 2026-10-12]` passes; `exit 3` and `v1.91.94.0` are ignored.
|
||||
|
||||
**B12. A compact Capy driver doc, one template, outside the parity union.** `[field]` `autoplan/sections/capy-driver.md.tmpl` renders to **`docs/capy-driver.md`** as a whole-file output registered in `.gstack/generated.json` (the parity harness unions every `sections/*.md`, `test/helpers/parity-harness.ts:78-82`, and the autoplan union sits at 117,121/101,979 = 1.1485 against the 1.1490 cap, ~50 bytes of headroom `[measured: Eng rev 2]`); `docs/unattended.md` links it (no fenced-region mechanism, which `gen-skill-docs` lacks). Content: phase order, artifact contract, the `next`/`submit`/`answer`/`revise` loop, the gate reply grammar, the recovery triad table, the RESULT FORMAT, no hook or guard prose; ≤ 200 lines `[target]`. The drive skill reads that path. A test pins the line cap, that no hook token (`PreToolUse`, `Stop hook`, `guard:`) appears, and that `bun test test/parity-suite.test.ts` shows the autoplan ratio unchanged. `review-record.md` links each bound record by name and sha256 instead of inlining it (byte cap pinned in the 3-rev fixture).
|
||||
|
||||
**B13. The Capy driver is honest about the checkout it runs on.** The first `gstack-autoplan next` of a run invokes `gstack-doctor --check --for autoplan` (through `process.execPath` and the bin path, receipted), writes `gstack_revision` and the `gstack:` trailer into `run.json` and the review-record run log, prints `GSTACK_TRAILER:` as the first line of its output, writes the installation lease (A1), and refuses `GSTACK_STALE_REFUSED` (exit 3) when the trailer is `fail`; the `gstack-skill-start` path stays for Claude Code. Fixture: a stale checkout driven by `gstack-autoplan next` in the sim refuses rather than runs.
|
||||
|
||||
### PR C: pregate (plan to gate, queue-aware shipping)
|
||||
|
||||
One flag grammar for every check: `gstack-pregate run [--only <checks>] --range <a>..<b>` (`--staged` as sugar; `--range a..b` implies pin files from `a` unless `--base` is given; the table header prints `POLICY_FROM: <ref>` and `TIER: unit (test tier)` so check tiers and lane platform tiers are not confused); `--only` stays literal; `install-hook` becomes `hook install|status|remove`. The pre-push ref parsing (zero remote sha → merge-base with the remote default; deletes skipped) is extracted from `bin/gstack-redact-prepush:604` into `lib/prepush-refs.ts` used by both guards; the managed pre-push wrapper becomes a guard list so `hook install|status|remove` adds or removes one guard while `pre-push.local` and the other guard survive, and the wrapper embeds the absolute Bun path (`process.execPath` at install time, `command -v bun` fallback) instead of `bun`. `bin/gstack-redact*` and `lib/prepush-refs.ts` are in C's owner column.
|
||||
|
||||
**C13. Lane-done runs the pre-gate on changed files.** `[field]` `gstack-pregate run --tier unit --range origin/<base>..HEAD --allow-repo-commands` is the lane-done preflight: stage 1, `lanes` restricted to the unit tier, `regen` (no duplicate `goldens` check), and the new `manifests` check; the receipt lists every promised preflight row, and a stale-generated-output fixture whose lane and manifest checks pass still fails on `regen`. `manifests` is the **gstack adapter** in release one (gate rev 2, d8): it imports `unseededFreeFiles` (`scripts/test-free-shards.ts:396`) and `TREE_MUTATING` (`:243`) and fails `MANIFEST_ROW_MISSING` with the exact row to add; an ordinary new test needs only its durations row, a renamed exclusive test its serial row, a platform-excluded test neither; a repository without a declared runner adapter reports `manifests: not configured` (never `pass`), and P4 is narrowed accordingly, with the adapter contract (census, durations, exclusivity, platform data) documented in `docs/pregate.md`. Receipts carry purpose, selection, required inventory and unexecuted obligations, so a green unit-only rerun cannot overwrite a publication receipt or clear a remote obligation. After B lands: `tasks.jsonl` gains `checks: string[]` (schema v3, `lib/headless-artifacts.ts:41,145`; `acceptance` stays prose), `lib/autoplan-export.ts:31` renders them, and `lib/coordinator-contract.ts:22` gains the exact sentence "before reporting done, run `gstack-pregate run --tier unit --range origin/<base>..HEAD --allow-repo-commands` and paste its table after `GSTACK_RESULT`". Fixtures: `pregate-lane-done-manifest-row` (adds an unseeded test file and renames a `TREE_MUTATING` key; both FAIL rows name the row to add); a fixture with a different exclusive-test map and one without duration manifests both report `not configured`.
|
||||
|
||||
**C14. Paid gates are priced from a declared adapter's structured plan before approval.** `[field]` `gstack-eval-plan dry-run --gate <cmd>` (under `--allow-repo-commands`) accepts only declared adapters emitting a complete structured plan; PR C ships the first one: `scripts/test-paid-shards.ts --list --json` (steps, calls, model, max tokens, trial multiplicity, preflight work, excluded charges, hidden-step rule), and the synopsis builders gain the same `--list --json`. It prices Σ(calls × model) through `lib/pricing.ts` and the ledger's reservation rule, persists a structured quote bound to adapter, resolved command, consumed-input hashes, models, limits and pricing revision, and prints `GATE_PRICE: <cmd> calls=<n> est=$<x> cap=$<y> evidence=list|smoke preconditions=<list|none>`. `--smoke 2` spends two real questions only with explicit consent and reserves at dispatch. A hidden step over the cap is `GATE_OVER_CAP` (exit 1) naming it; a quote whose inputs changed is invalid at spend time; an unknown model is refused. **Consequence stated (gate rev 2, d2):** a paid gate in a repository without a declared adapter is `GATE_UNPRICED`; in release one that renders as an owner-answerable gate item (`accept unpriced <cmd> under cap $x` / `block`) rather than an unconditional blocker, so the wave does not freeze gbrain shipping on day one; the gbrain synopsis-build adapter (the $723 field case) is a named deliverable with an owner outside this plan, and the adapter contract is published in `docs/pregate.md` so a repo can comply in one PR. After B lands, the plan `numbers` row requires a `GATE_PRICE:` line for every paid gate the plan names, printing `GATE_PRICE: unavailable (adapter not installed)` without blocking while the repo has no adapter. No `GSTACK_EVAL_DRY_RUN`. Fixtures: `gate-price-hidden-synopsis` exercises the real `--list --json` planner with a stubbed paid boundary (100-call plan under cap 10 → `GATE_OVER_CAP` naming the hidden step); changed-input refusal; an undeclared command → `GATE_UNPRICED` item.
|
||||
|
||||
**D15. Ship policy lets the repo's script own the stamp; the tree receipt proves stamp-only; failures never pass.** `[field]` `release_tool` stays the one key and gains `release_tool_owns: 'post-stamp' | 'whole-stamp'` (default `post-stamp`); `GSTACK_NEW_VERSION`, `GSTACK_OLD_VERSION` and `GSTACK_BASE_SHA` are exported in both modes (`lib/restamp.ts:310`). Whole-stamp skips the version/changelog/`stamp_paths` stages and keeps the journal, the allowed-output set, pre/postimage and `MERGE_CONDITION`; every post-command output is diffed against an immutable pre-command tree, index or HEAD mutation by the tool is refused, and the authoritative version, declared mirrors and changelog heading are validated independently afterwards (exit-zero no-op at the old version fails; already-at-target is a legitimate no-op). `--dry-run` prints `STAGES: version=skipped(release_tool owns) …`. A pregate `stamp` check (`run --only stamp --range <gated-head>..HEAD --evidence <dir>|--gate-ahead <record>`) calls `buildReceipt` with an explicit evidence source and prints `STAMP_ONLY: yes|no` and `REGATE: needed|not needed <lanes>` from the receipt (`STAMP_NOT_ONLY` on `no`; `STAMP_EVIDENCE_MISSING` with the recovery when the bundle is absent); stamp-only equality and per-lane evidence reuse are separate rows. `classifyFile` (`lib/tree-receipt.ts:69`) gains one `migration-number` rule backed by raw tree-entry comparison, so mode and object-type changes are refused rather than read as empty hunks, and a failed or overflowed `git diff`, enumeration or blob read propagates as `incomplete`/non-reusable instead of an empty (passing) comparison (`lib/tree-receipt.ts:140`). `MIGRATION_COLLISION` compares candidate ids against the target base's current migrations plus a complete, paginated open-PR census (`gh pr list --json number,files` pages past 100 files and 30 PRs, bound to head SHAs, receipted as `pregate-migration-collision`), rechecks at the serialised merge position, and refuses `MIGRATION_CENSUS_INCOMPLETE` (never `pass`) on a failed page or without `gh`; semantic renumbering requires a regate. Fixtures: extend the tree-receipt tests with a gbrain-shaped fixture (`release-restamp.ts`, `since:` rows, two PRs naming v240, a collision in PR 31, an open-to-merged predecessor, different filenames sharing an id, a chmod-only change on a stamp path, a failed diff that may not print `STAMP_ONLY: yes`).
|
||||
|
||||
**D16. Sealed-output guard before push and in the hook, bound to the destination.** `[field]` Sealed rules live under `checks.sealed` in `.gstack/pregate.json` (`public`, `deny` rules for transcript paths, answer-text fences, seed files and machine-local prefixes, each with narrow per-rule `except` globs so documented placeholders pass while a sealed answer in the same file still fails; `allow` globs; `max_bytes`, default 2 MB `[target]`), reusing its schema, containment check and `gstack-pregate init`; an unconfigured check reports `not configured` and the ship receipt treats that as not-required, never passed. The rule grammar is anchored literals, globs and a fixed set of named detectors, or a regex compiled under a per-rule time budget that yields `SEALED: incomplete <file> rule=<name> (timeout)`; the sealed check reads its policy from head without `--allow-repo-commands` since it executes no command. `run --only sealed --range <a>..<b> | --staged` applies path denies to every outgoing blob before decoding, inspects actual bytes independent of diff attributes (NUL-bearing, `-diff`, UTF-16 content included), returns `incomplete` for non-exempt uninspectable content, and reports over-cap files as `SEALED: incomplete <file> (<bytes>)` with the fix line (`add "<path>" to allow, or raise max_bytes`); allow-globbed files print `SEALED: skipped <file> rule=allow`. Receipts are bound to the receiving remote, the outgoing refs and object ids, the policy hash and the scanner version; the scanned history excludes only commits established to exist at that destination, so a push to a new or unknown destination scans the full reachable history or is `incomplete`; staged-only evidence never authorises publication, and a receipt is stale once leaking history is added even when the final tree is identical. Refusals print `SEALED: <ref> <commit>:<blob> <path>:<line> rule=<name> status=current|historical (SEALED_CONTENT_REFUSED)` with no sealed snippet, and already-published history routes to the owner with non-destructive inspection guidance rather than an automatic force-push. `hook` mode reads the pre-push stdin refs through `lib/prepush-refs.ts`. `/ship` runs `--range origin/<base>..HEAD` before `git push`; `gstack-ship-receipt write` refuses `SEALED_RESULT_MISSING` without a current result when the check is configured. After B lands, `lib/coordinator-contract.ts` gains the sentence every lane inherits: "Before any push, run `gstack-pregate run --only sealed --range origin/<base>..HEAD`; a lane never pushes without a current `SEALED:` pass or `not configured` result, and pastes that line in its handoff after `GSTACK_RESULT`" (the field leak was a lane's direct push, not `/ship`; G6 is judged against that path). Fixtures: `pregate-sealed-refuses-transcript` drives the hook through stdin for a new branch carrying a per-cell transcript, a quoted sealed answer, a 20 MB `[fixture]` generated file and a `public: false` negative; a private origin with denied base-history content pushed to an empty public destination is refused; a pathological rule (`(a+)+$`) over a 1 MB `[fixture]` blob completes as `incomplete`; a clean receipt followed by add-then-delete transcript commits with an identical final tree is stale.
|
||||
|
||||
### Not in this wave
|
||||
|
||||
Scrubbing gbrain's history (URGENT above, owner's call); the gbrain paid-gate adapter (named deliverable, owner outside this plan); runner adapters for `manifests` beyond gstack's; snapshot baking itself (documented, not automated); Windows paths for the simulated host; the `mirror` restamp tier; `/eval-plan` tier 3; CODEOWNERS-driven anything; a Capy-native hook mechanism (the driver doc says the guard is not enforced, as today); crash injection after every revise step beyond the one `failAfter` test; migration-identity adapters beyond the policy glob.
|
||||
|
||||
### Validation
|
||||
|
||||
Every item: a failing test first on the simulated Capy host, then the fix, then the test passes; never a loosened assertion. Cheap gates before paid: `bun run typecheck`, `typecheck:test`, `gen:skill-docs --host all` clean, `gstack-regen check`, parity suite, every ratchet (module-size, state-root, spawnsync, troubleshooting-anchors, headless-artifacts, free-fixtures, egress-receipt-wiring, bun-spawn, harness-version), `format:cso:check`, credential scan; `bun run test:quick`; `gstack-pregate run --tier unit --range` once C13 lands (dogfood). Paid: the autoplan/ship/review workflow judges on changed prompts only. Acceptance, each a named test: `install-stale-1-91-12-self-upgrades`, `install-ok-then-check-ok` (positive and the no-network negative), `upgrade-refuses-divergence-keeps-bytes`, `self-upgrade-policy-matrix`, `autoplan-3-rev-validates` (B5, B6, B8 together), `gate-defaults-follow-agreement` (B9), `scope-doctor-status-description-is-not-ui` (B10), `driver-refuses-stale-checkout` (B13), `pregate-lane-done-manifest-row` (C13), `gate-price-hidden-synopsis` (C14), `tree-receipt-gbrain-shape` (D15), `pregate-sealed-refuses-transcript` (D16). Integrated acceptance after review fixes, generation and release metadata: freeze the tree, `bun run test` once on it, PR A's Docker smoke receipt on the gated SHA (reused through the tree receipt on the stamped tree), then the selected judges. Done means: all twelve pass on the simulated host; a 3-rev unattended autoplan run validates; a synthesised 1.91.12 checkout upgrades itself to current at session start; and the drive skill is regenerated from `scripts/capy/SKILL.md.tmpl` with the bootstrap from `scripts/capy/bootstrap.sh` (no promise of a command that does not exist; `--check` at session start, lazy install, `GSTACK_LAUNCH_FILE`, the trailer in a subagent's first report).
|
||||
|
||||
### Effort
|
||||
|
||||
| PR | New bins / libs / fixtures | Est. LOC `[estimated]` | Notes |
|
||||
|---|---|---|---|
|
||||
| A | 2 / 0 / 6 | 1,100–1,500 | bash upgrade lib shared with session-update; installation leases; host sim with synthesised origin; bootstrap, in-repo wrapper, drive skill template and pin tests |
|
||||
| B | 0 / 3 / 8 | 1,800–2,400 | `lib/autoplan-gate.ts` (215 lines), `lib/autoplan-run.ts`, `lib/autoplan-prompts.ts`, `lib/gate-list.ts`, `lib/autoplan-reconcile.ts`; new `lib/autoplan-revise.ts`, `lib/decision-ref.ts`, `lib/phase-inputs.ts` |
|
||||
| C | 0 / 4 / 7 | 1,800–2,400 | `lib/pregate/checks/{manifests,stamp,sealed}.ts`, `lib/prepush-refs.ts`, `release_tool_owns`, `--list --json`, eval-plan `--gate`, one tree-receipt rule, guard-list hook |
|
||||
|
||||
### Gate decisions
|
||||
|
||||
Resolved at gate rev 1 (all 17 reviewer challenges answered `b`): G1 → dependency chain A → B → C with one owner per shared file; G2 → bash `bin/gstack-upgrade` sharing session-update's stages; G3 → (b) plus the PATH-bearing launch line; G5 → one template rendering the Capy driver doc. Resolved at gate rev 2 (owner answered `d1b d2b d3b d4a d5b d6b d7b d8b`): the drive carries a logic-free bootstrap; `GATE_UNPRICED` is owner-answerable in release one with the adapter contract published; G8 → option (c), always current when online, floor refuses, stale note offline; `--attempt new` is dropped (d4 moot); transitive invalidation of dependent phases; installation-scoped leases; `manifests` is the gstack adapter only. Still the owner's, each a draft direction that stands until decided:
|
||||
|
||||
- **G4. A4.** (a) **Recommended:** lazy install at first workflow use from the wrapper plus the snapshot-baking doc. (b) Snapshot-baking doc only. Draft direction stands until the owner decides.
|
||||
- **G6. D16 enforcement.** (a) **Recommended:** the coordinator contract makes every lane run the sealed check before any push, `/ship` runs it too and the receipt refuses without it; the pre-push hook is opt-in via `hook install`. (b) Hook only. Draft direction stands until the owner decides.
|
||||
- **G7. The simulated host.** (a) **Recommended:** hermetic simulation in the free suite plus one Docker smoke in a dispatch lane, cleared only by a matching job receipt reused through the tree receipt. (b) Docker only. Draft direction stands until the owner decides.
|
||||
|
||||
## Review record
|
||||
|
||||
### Gate rev 1 (run `fixwave-run-20261012204516`, snapshot `6f25ac67…`, order ceo, dx, eng; scope ui=no dx=yes source=flags)
|
||||
|
||||
CEO: native 18 findings (1 Critical, 8 High, 8 Medium, 1 Low), outside 14 (`new=14 native_only=18`). DX: native 15, outside 8. Eng: native 17 (5 High, 10 Medium, 2 Low), outside 12 (10 High, 2 Medium). Seventeen user challenges rendered as gate items d1–d17; the owner answered every one `b` and `p1b` (`REOPENED: ceo,dx,eng`); the Eng-stated reconciliations were adopted: `all` leaves agreed `uc` items pending and reopen keys on the chosen option (CEO native-5 + outside-6 against B9), and the single upgrade implementation is bash sharing session-update's stages (Eng native-1 over DX native-1's `lib/upgrade.ts`).
|
||||
|
||||
### Gate rev 2 (plan revision 2, snapshot `472342d2…`, same order and scope)
|
||||
|
||||
CEO: native 10 findings (4 High, 5 Medium, 1 Low), outside 4 (3 High, 1 Medium). DX: native 17 (4 High, 8 Medium, 5 Low), outside 7 (3 High, 4 Medium); the driver omitted the CEO consensus from both DX prompts (B10 defect, reproduced). Eng: native 22 (3 High, 10 Medium, 8 Low, 1 Informational), outside 9 (6 High, 3 Medium). Eight gate items d1–d8; the owner answered `d1b d2b d3b d4a d5b d6b d7b d8b p1b` (d4 moot once d5 dropped `--attempt new`). All non-challenge High and Medium findings were folded into revision 3 as the author's own decisions; they are listed in the accepted blocks below. The run itself reproduced B5: `gstack-artifact validate run.json` reports 9 `ARTIFACT_DUPLICATE_ID` errors after this revision.
|
||||
|
||||
<!-- autoplan-accepted:ceo -->
|
||||
- Lanes form a dependency chain A → B → C with one owner per shared file, pre-assigned result codes and anchors, and C's "after B lands" shared-file list; verification: the ownership table and no two PRs touching an owned file.
|
||||
- The staleness authority lives outside the checkout; the drive carries a logic-free bootstrap that fetches and executes the fetched tree's in-repo wrapper; policy order floor → pin → off switch → fetch-under-deadline → always current when online, stale note offline; verification: `install-stale-1-91-12-self-upgrades` and `self-upgrade-policy-matrix`.
|
||||
- The runtime row is honest to `command -v bun`; `GSTACK_LAUNCH` carries PATH and a parsed `launch.env`; the installer's final check runs in a fresh environment; verification: `install-ok-then-check-ok` and its negative.
|
||||
- `all` never flips the owner's stated direction; agreed items stay pending until answered by token; reopen keys on the chosen option; answers carry `answered_by`; verification: `gate-defaults-follow-agreement`.
|
||||
- No `--record` and no `--attempt new`; `submit --result` is extended with `--extract` and `--outcome`; packaging choices are visible auto items; dedupe requires an explicit prior-item link; verification: the 3-rev fixture.
|
||||
- Gate pricing accepts only declared adapters with complete structured plans; `GATE_UNPRICED` is owner-answerable in release one and the adapter contract is published; figures are classified and only decision-driving measurements need receipts; verification: `gate-price-hidden-synopsis` and the plan-reality fixtures.
|
||||
- The Capy driver runs the doctor on its first `next` and refuses a `fail` trailer; the coordinator contract makes every lane run the sealed check before any push; verification: `driver-refuses-stale-checkout` and the contract pin test.
|
||||
- Sealed scanning covers every newly reachable blob of the outgoing refs, bound to the destination; the Docker obligation is reused through the tree receipt; the existing disclosure is triaged separately (URGENT block).
|
||||
- The Claude upgrade skill delegates to `bin/gstack-upgrade` and loses its reset fallback; sealed rules live under `checks.sealed` in `pregate.json`.
|
||||
- Integrated acceptance: frozen tree, one final `bun run test`, PR A's Docker receipt on the gated SHA, selected judges.
|
||||
<!-- /autoplan-accepted:ceo -->
|
||||
|
||||
<!-- autoplan-accepted:dx -->
|
||||
- `scripts/capy/bootstrap.sh`, `scripts/capy/install.sh` and `scripts/capy/SKILL.md.tmpl` are in-repo with pin tests; the wrapper owns the first hop and lazy install, prints every trailer on stdout, and pins its anchors; `gstack-skill-start` prints `SELF_UPGRADE:` and its unattended prose matches; a `--to` pin skips rather than refuses and one off switch exists.
|
||||
- Every failure line carries a result code and anchor; the per-PR code lists above are the enumeration.
|
||||
- The runtime FAIL row names the off-PATH verified Bun with the `launch.env` fix; `gstack-doctor --check` prints `GSTACK_LAUNCH_FILE:` itself; JSON carries `path_prepend` without shell syntax.
|
||||
- `answer` prints `REVISE:` before `NEXT:` and `next` refuses `PLAN_UNCHANGED_SINCE_GATE`; `GATE_PENDING` prints exact tokens; incremental answers compose; `--extract` has a heading grammar; the plan-side numbers scan runs before dispatch and keeps `unreceipted=<row ids>`.
|
||||
- `release_tool` stays the one key with `release_tool_owns`; one `--range` grammar with `POLICY_FROM`/`TIER` headers; `hook install|status|remove` composed through the guard-list wrapper; `autoplan.json` has init and schema; the sealed cap has a stated default, a `skipped` verdict and a fix line; the stacked-stamp regate count is stated.
|
||||
- One Capy driver template renders `docs/capy-driver.md`; every later phase receives predecessor consensus; `--json` returns one versioned object on every path; a host-subagent failure has a terminal outcome command; revise refuses while attempts are open and can replace or reset scope flags.
|
||||
<!-- /autoplan-accepted:dx -->
|
||||
|
||||
<!-- autoplan-accepted:eng -->
|
||||
- `bin/gstack-upgrade` is bash over a sourced lib extracted from session-update; conflict recovery preserves bytes and stash; `--to` distinguishes tags from commits and verifies the target; the busy predicate is a live installation lease outside the caller's ancestry plus the installer flock; the wrapper prepends the supported Bun before merge, setup and check.
|
||||
- The stale checkout is synthesised from a local bare origin cloned depth-1 under `GIT_ALLOW_PROTOCOL=file` and a dead proxy; simulator environments are allowlisted and sentinel-tested; fixtures share one upgraded checkout where the scenario allows.
|
||||
- lib spawns and generated hooks use `process.execPath`; `test/bun-spawn-ratchet.test.ts` guards it.
|
||||
- Finding rows carry `attempt`; cross-revision references are attempt-qualified; decision references go through one revision-aware constructor; `Scope.ui` is `yes|no|unknown` and revise re-detects scope with `REVISE_SCOPE_CHANGED`.
|
||||
- `revise` replaces Implementation bytes only, checks obligations, performs a journaled generation transition with idempotent resume, and invalidates transitive consumers of changed inputs; `PLAN_SHAPE_PARTIAL` replaces the scaffold idea.
|
||||
- The B9 agreement rule is mechanical (both `user_challenge` and resolution token overlap, or an `agreement` override).
|
||||
- The numbers row scopes to unit-bearing figures with `[measured]`/`[estimated]`/`[target]`/`[fixture]` tags and `file:line` receipts; reality and price checks run as a persisted validation boundary.
|
||||
- `manifests` imports the shard runner's own inventories as the gstack adapter and reports `not configured` elsewhere; `goldens` is dropped for the existing `regen`; tasks gain `checks[]`; receipts are versioned by purpose and selection; the Docker obligation clears only on a matching job receipt.
|
||||
- `--gate` consumes the new `--list --json` plans; quotes are input-bound; no `GSTACK_EVAL_DRY_RUN`.
|
||||
- The stamp-only proof is the tree receipt plus one `migration-number` rule with raw tree-entry comparison; failed comparisons are `incomplete`, never stamp-only; the stamp check takes an explicit evidence source; delegated stamps are diffed against an immutable pre-tree with independent postconditions; the migration census is paginated, SHA-bound and refuses when incomplete.
|
||||
- The sealed check inspects every outgoing blob's bytes before decoding, restricts its rule grammar or budgets regex time, reads policy without `--allow-repo-commands`, binds receipts to destination, refs, objects, policy and scanner version, and prints object identities without snippets; the hook shares `lib/prepush-refs.ts`.
|
||||
- Egress sites are receipted under named labels with the single bootstrap exemption; `review-record.md` links bound records by sha; the bootstrap is fail-open on the egress lib exactly like session-update.
|
||||
<!-- /autoplan-accepted:eng -->
|
||||
@@ -1193,6 +1193,26 @@ listed changes, check out the default branch, move local commits to another
|
||||
branch), then re-run `bin/gstack-upgrade`. A reinstall that failed after the
|
||||
fast-forward is completed by the next run (`reinstall pending from <version>`).
|
||||
|
||||
<a id="upgrade-busy"></a>
|
||||
### `gstack-upgrade: live gstack sessions hold this installation: pid <p> (<age>m), ...; nothing was changed; fix: ... (UPGRADE_BUSY)`
|
||||
|
||||
**Meaning.** Several agents share this checkout (Capy subagents on one
|
||||
machine, say). Each session start writes a workflow lease,
|
||||
`<git dir>/gstack-leases/<harness pid>` (`bin/gstack-lease touch`, from
|
||||
`bin/gstack-skill-start`), and `bin/gstack-upgrade` refused before changing
|
||||
anything because a lease whose pid is alive and not in its own process
|
||||
ancestry exists: fast-forwarding would change gstack under a session
|
||||
mid-workflow. Leases older than 120 minutes are pruned; a lease whose
|
||||
process has exited never blocks. The upgrader's own session (the pid chain it
|
||||
runs under) never counts.
|
||||
|
||||
**Fix.** Wait for those sessions to finish (`bin/gstack-lease list` shows
|
||||
every lease with its age and `alive|dead`), then re-run `bin/gstack-upgrade`.
|
||||
When you know they are idle, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade`
|
||||
proceeds. At a Capy session start the same refusal appears as
|
||||
`SELF_UPGRADE: skipped (live gstack sessions hold this installation: ...)`
|
||||
and the session continues on the installed revision.
|
||||
|
||||
<a id="upgrade-fetch-failed"></a>
|
||||
### `gstack-upgrade: could not reach origin (...) (UPGRADE_FETCH_FAILED)`
|
||||
|
||||
@@ -1838,6 +1858,20 @@ applied; the unparsed tokens are listed.
|
||||
`all except <tokens>`. A bare `yes` or `no` is ambiguous across multi-option
|
||||
items and is never guessed.
|
||||
|
||||
<a id="answer-by-required"></a>
|
||||
### `ANSWER_BY_REQUIRED`
|
||||
|
||||
**Meaning.** `gstack-autoplan answer` ran in an unattended run without
|
||||
`--by`, or with a value that is neither `human` nor `agent:<id>`. The
|
||||
artifacts must say who answered a gate: an owner typing tokens and an agent
|
||||
relaying or deciding them look identical in `decisions.jsonl` otherwise.
|
||||
Nothing was applied.
|
||||
|
||||
**Fix.** Re-run with `--by human` when the owner typed the tokens, or
|
||||
`--by agent:<thread or id>` when an agent relays or decides. The value is
|
||||
recorded as `answered_by` on every decision row the reply answers and in the
|
||||
review record's gate line.
|
||||
|
||||
## Queue-aware ship: policy, restamp, tree receipt, ship receipt
|
||||
|
||||
`gstack-ship-policy`, `gstack-restamp`, `gstack-tree-receipt`,
|
||||
|
||||
+16
-6
@@ -186,22 +186,32 @@ the plan.
|
||||
`PHASE_NOT_AWAITING`, exit 3). Both voices in closes the phase: the
|
||||
reconciliation (`confirmed` / `disagree` / `new` per outside finding, with
|
||||
disagreement and resolution kept separate) is written to
|
||||
`<phase>-consensus.{json,md}`, and the Eng prompt refuses to open without
|
||||
every closed prior phase's consensus (`CONSENSUS_MISSING`).
|
||||
`<phase>-consensus.{json,md}`, and every later phase's prompts (DX, design
|
||||
and Eng alike) carry each closed predecessor's consensus as data; a phase
|
||||
refuses to open without one (`CONSENSUS_MISSING`).
|
||||
3. **Loop `next` until `status=gate_pending`.** Eng always runs last. The gate
|
||||
(`gate.json`, `gate_rev`) lists user challenges as approval items,
|
||||
disagreements as auto items and `p1` plan approval; nothing is approved.
|
||||
4. **Answer the gate** with the reply grammar and the revision you read:
|
||||
```bash
|
||||
$AP answer --out <dir> --gate-rev 1 --reply "all" # every recommendation; agreeing plan changes print APPLY:
|
||||
$AP answer --out <dir> --gate-rev 1 --reply "all as-is" # APPROVED: keeps every direction, no new paid phase
|
||||
$AP answer --out <dir> --gate-rev 1 --reply "all except d1b" # APPLY: <id> ...; REOPENED: ceo,eng; NEXT: gstack-autoplan revise ...
|
||||
$AP answer --out <dir> --gate-rev 1 --reply "all" --by human # every recommendation the owner may delegate; agreeing plan changes stay pending
|
||||
$AP answer --out <dir> --gate-rev 1 --reply "all as-is" --by human # APPROVED: keeps every direction, no new paid phase
|
||||
$AP answer --out <dir> --gate-rev 1 --reply "all except d1b" --by human # APPLY: <id> ...; REOPENED: ceo,eng; NEXT: gstack-autoplan revise ...
|
||||
```
|
||||
`--by human` means the owner typed the tokens; `--by agent:<thread or id>`
|
||||
means an agent relayed or decided them. An unattended run refuses an answer
|
||||
without it (`ANSWER_BY_REQUIRED`, exit 3) and records the value as
|
||||
`answered_by` on every decision row and in the review record's gate line,
|
||||
so a gate an agent closed is distinguishable from one the owner closed.
|
||||
A user challenge both voices raised on a confirmed finding with the same
|
||||
`changes` value is recommended as `b`, and option b shows both voices'
|
||||
remedies (`recommended: b (both voices; remedies shown)`); a `--reconcile`
|
||||
record naming one shared `proposal` shows that instead. Disagreeing or
|
||||
missing remedies keep `a`. Every accepted plan change prints
|
||||
missing remedies keep `a`. `all` never answers an item recommended `b`:
|
||||
that would change the owner's stated direction on their behalf, so those
|
||||
items stay pending (`GATE_PENDING: uc1 unanswered (reply with uc1a|uc1b; all
|
||||
does not answer items recommended b)`) until their own token arrives, and
|
||||
the rendered gate names them under the reply grammar. Every accepted plan change prints
|
||||
`APPLY: <decision id> <title> -> <chosen text>` and becomes a pending
|
||||
amendment: `next` refuses with `PLAN_AMEND_PENDING` and `export` refuses a
|
||||
`complete` run until `revise --incorporates` names it (see below).
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* hosts' ambient spawned sessions (a warning line), are not refused.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { RunError } from './autoplan-state';
|
||||
import { checkInstallAdmission } from './install-verdict';
|
||||
@@ -17,6 +18,18 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
export interface Admission { lines: string[]; deviation?: string }
|
||||
|
||||
/**
|
||||
* A workflow lease on the shared installation (bin/gstack-lease): the upgrader
|
||||
* refuses UPGRADE_BUSY while a live lease it does not own exists, so a
|
||||
* session-start fast-forward cannot move the checkout under a running
|
||||
* autoplan driver. Fail-open: a missing or failing bin never blocks the run.
|
||||
*/
|
||||
export function touchInstallLease(env: NodeJS.ProcessEnv = process.env): void {
|
||||
const bin = path.join(ROOT, 'bin', 'gstack-lease');
|
||||
if (process.platform === 'win32' || !fs.existsSync(bin)) return;
|
||||
try { spawnSync(bin, ['touch', '--root', ROOT, '--from', String(process.ppid)], { encoding: 'utf8', timeout: 10_000, env, stdio: 'ignore' }); } catch { /* fail-open */ }
|
||||
}
|
||||
|
||||
/** Throws RunError (exit 3) unless the session may write; an override or a warning returns the line to print. */
|
||||
export function admit(env: NodeJS.ProcessEnv = process.env): Admission {
|
||||
const bunVersion = spawnSync('bun', ['--version'], { encoding: 'utf8', timeout: 30_000, env }).stdout?.trim() || '0.0.0';
|
||||
|
||||
@@ -81,7 +81,7 @@ function runLog(state: RunState, findings: number, tasks: number, decisions: { t
|
||||
}
|
||||
for (const h of state.history) lines.push(`Reopened ${h.phase} at gate_rev ${h.gate_rev}: ${h.reason} (${h.at})`);
|
||||
if (state.history.length) lines.push('');
|
||||
lines.push(`Gate: ${state.gate_rev ? `gate_rev ${state.gate_rev}, ${decisions.total} decisions (${decisions.pending} pending)${state.gate && typeof (state.gate as any).reply === 'string' ? `; answered "${(state.gate as any).reply}"` : ''}` : 'not reached'}. Findings: ${findings}. Tasks: ${tasks}.`);
|
||||
lines.push(`Gate: ${state.gate_rev ? `gate_rev ${state.gate_rev}, ${decisions.total} decisions (${decisions.pending} pending)${state.gate && typeof (state.gate as any).reply === 'string' ? `; answered "${(state.gate as any).reply}"${typeof (state.gate as any).answered_by === 'string' ? ` answered by ${(state.gate as any).answered_by}` : ''}` : ''}` : 'not reached'}. Findings: ${findings}. Tasks: ${tasks}.`);
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { consensusRecord, reconcile, renderConsensus, type ConsensusRecord, type
|
||||
import type { Phase } from './autoplan-prompts';
|
||||
import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, writeDecisions, type RunState, type VoiceState } from './autoplan-state';
|
||||
import { phaseClose } from './autoplan-timing';
|
||||
import { applyReply, decisionId, decisionRows, keepDirection, parseReply, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption } from './gate-list';
|
||||
import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption } from './gate-list';
|
||||
import type { FindingRow } from './headless-artifacts';
|
||||
import { readJsonl } from './headless-artifacts';
|
||||
import { closeDraftDirectionsFile, loadBriefInputs } from './owner-brief';
|
||||
@@ -189,9 +189,11 @@ export interface AnswerResult { state: RunState; applied: boolean; pending: stri
|
||||
* prints `APPLY:` and becomes a pending amendment (`next` refuses until
|
||||
* `revise --incorporates` or `--withdraw`).
|
||||
*/
|
||||
export function answerGate(state: RunState, gateRev: number, reply: string): AnswerResult {
|
||||
export function answerGate(state: RunState, gateRev: number, reply: string, answeredBy?: string): AnswerResult {
|
||||
const gateFile = path.join(state.out, 'gate.json');
|
||||
if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3);
|
||||
if (answeredBy !== undefined && !ANSWERED_BY_RE.test(answeredBy)) throw new RunError('ANSWER_BY_REQUIRED', `--by ${JSON.stringify(answeredBy)} is neither human nor agent:<id>`, 3);
|
||||
if (answeredBy === undefined && state.session_kind === 'unattended') throw new RunError('ANSWER_BY_REQUIRED', 'an unattended run records who answered; pass --by human or --by agent:<id>', 3);
|
||||
const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList;
|
||||
const rows = readDecisions(state.out);
|
||||
const prior = Object.fromEntries(rows.filter(r => r.gate_rev === list.gate_rev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!]));
|
||||
@@ -203,7 +205,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string): Ans
|
||||
const plan = parsed.choices.find(c => c.id === 'p1');
|
||||
const lines: string[] = [];
|
||||
const answeredAt = now();
|
||||
const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt).map(r => {
|
||||
const record = () => writeDecisions(state.out, applyReply(rows, parsed, answeredAt, answeredBy).map(r => {
|
||||
const c = r.gate_rev === list.gate_rev && r.chosen && !r.chosen_text ? itemOf((r.label ?? '').toLowerCase())?.options.find(o => o.key === r.chosen) : undefined;
|
||||
return c ? { ...r, chosen_text: c.text } : r;
|
||||
}));
|
||||
@@ -212,7 +214,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string): Ans
|
||||
record();
|
||||
const pending = parsed.pending.length ? parsed.pending : ['p1'];
|
||||
state.gate = { ...(state.gate ?? {}), last_reply: reply, pending };
|
||||
lines.push(`GATE_PENDING: ${pending.join(', ')} unanswered (reply with p1a..p1e or all)`);
|
||||
lines.push(`GATE_PENDING: ${pending.join(', ')} unanswered (${pendingHint(list, pending)})`);
|
||||
return { state, applied: true, pending, reopened: [], outcome: 'gate_pending', lines };
|
||||
}
|
||||
const proceeds = plan.chosen !== 'c' && plan.chosen !== 'e';
|
||||
@@ -227,8 +229,8 @@ export function answerGate(state: RunState, gateRev: number, reply: string): Ans
|
||||
if (phase) { reopenSet.add(phase); if (state.phase_order.includes('eng')) reopenSet.add('eng'); }
|
||||
}
|
||||
record();
|
||||
state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides) };
|
||||
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet] });
|
||||
state.gate = { ...(state.gate ?? {}), answered_at: answeredAt, reply, gate_rev: gateRev, plan: plan.chosen, overrides: tokens(overrides), ...(answeredBy ? { answered_by: answeredBy } : {}) };
|
||||
journalRow(state.out, { event: 'answered', gate_rev: gateRev, reply, plan: plan.chosen, overrides: tokens(overrides), changes: tokens(changing), reopen: [...reopenSet], ...(answeredBy ? { answered_by: answeredBy } : {}) });
|
||||
if (plan.chosen === 'c') {
|
||||
lines.push(`GATE_PENDING: interrogate recorded; the gate stays at gate_rev=${gateRev}`);
|
||||
return { state, applied: true, pending: ['p1'], reopened: [], outcome: 'gate_pending', lines };
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface Scope {
|
||||
}
|
||||
|
||||
export function snapshotTool(args: string[], cwd: string): any {
|
||||
const r = spawnSync('bun', [SNAPSHOT_TOOL, ...args], { cwd, encoding: 'utf8', timeout: 120_000, env: process.env });
|
||||
const r = spawnSync(process.execPath, [SNAPSHOT_TOOL, ...args], { cwd, encoding: 'utf8', timeout: 120_000, env: process.env });
|
||||
if (r.status !== 0) throw new Error(`gstack-autoplan-snapshot ${args[0]}: ${(r.stderr || r.stdout).trim()}`);
|
||||
return JSON.parse(r.stdout);
|
||||
}
|
||||
|
||||
+9
-7
@@ -145,14 +145,16 @@ function openPhase(state: RunState, phase: Phase, cwd: string): void {
|
||||
if (ps.snapshot) return;
|
||||
const methodology = snapshotTool(['methodology', phase, reviewSkillFile(phase), state.restore], cwd);
|
||||
const snap = snapshotTool(['create', phase, state.active_plan, state.restore, methodology.methodologyPath], cwd);
|
||||
// Every later phase reviews with its closed predecessors' consensus as data
|
||||
// (not only Eng): a DX or design reviewer who never saw the CEO consensus
|
||||
// re-raises what the CEO phase already reconciled (field gate rev 2).
|
||||
const prior: Array<{ phase: string; text: string }> = [];
|
||||
if (phase === 'eng') {
|
||||
for (const p of state.phase_order) {
|
||||
if (p === 'eng' || !state.phases[p]?.closed_at) continue;
|
||||
const file = path.join(state.out, `${p}-consensus.md`);
|
||||
if (!fs.existsSync(file)) throw new RunError('CONSENSUS_MISSING', `${p} closed without ${p}-consensus.md`, 3);
|
||||
prior.push({ phase: p, text: fs.readFileSync(file, 'utf8') });
|
||||
}
|
||||
for (const p of state.phase_order) {
|
||||
if (p === phase) break;
|
||||
if (!state.phases[p]?.closed_at) continue;
|
||||
const file = path.join(state.out, `${p}-consensus.md`);
|
||||
if (!fs.existsSync(file)) throw new RunError('CONSENSUS_MISSING', `${p} closed without ${p}-consensus.md`, 3);
|
||||
prior.push({ phase: p, text: fs.readFileSync(file, 'utf8') });
|
||||
}
|
||||
const sections = sectionLoadFor(phase, state.scope, state.session_kind === 'unattended' || state.light ? 'light' : 'full');
|
||||
const decided = (state.gate_rev ?? 0) >= 1 ? readDecisions(state.out).filter(r => r.chosen != null && r.status !== 'pending')
|
||||
|
||||
+25
-5
@@ -68,9 +68,25 @@ export function validateGateList(list: unknown): string[] {
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** The `all` resolution: every item takes its recommendation; shown under the list so the reply shape is explicit. */
|
||||
/**
|
||||
* An approval item whose recommended option changes the plan (both voices
|
||||
* recommend the change) is answered only by its own token: `all` must never
|
||||
* flip the owner's stated direction on their behalf (field gate rev 1, d3).
|
||||
*/
|
||||
export function heldFromAll(item: GateItem): boolean {
|
||||
return item.kind === 'approval' && item.options.some(o => o.key === item.recommended && o.changes_plan === true);
|
||||
}
|
||||
|
||||
/** The `all` resolution: every item takes its recommendation, except the ones `heldFromAll` leaves to the owner; shown under the list so the reply shape is explicit. */
|
||||
export function resolveAll(list: GateList): Record<string, string> {
|
||||
return Object.fromEntries(list.items.map(i => [i.id, i.recommended]));
|
||||
return Object.fromEntries(list.items.filter(i => !heldFromAll(i)).map(i => [i.id, i.recommended]));
|
||||
}
|
||||
|
||||
/** The `GATE_PENDING` hint: exact tokens per pending item, and the `all` rule when any pending item is held from it. */
|
||||
export function pendingHint(list: GateList, pending: string[]): string {
|
||||
const items = pending.map(id => list.items.find(i => i.id === id)).filter((i): i is GateItem => !!i);
|
||||
const tokens = items.map(i => i.options.map(o => `${i.id}${o.key}`).join('|')).join(' ');
|
||||
return `reply with ${tokens || 'p1a..p1e'}${items.some(heldFromAll) ? '; all does not answer items recommended b' : ' or all'}`;
|
||||
}
|
||||
|
||||
export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): string {
|
||||
@@ -92,6 +108,7 @@ export function renderGate(list: GateList, page = 1, pageSize = PAGE_SIZE): stri
|
||||
if (pages > 1) lines.push(`Ids are stable across pages; render page N with \`gstack-gate render <file> --page N\`.`);
|
||||
lines.push('Reply with exactly one of: `all` · `<id><option>` tokens, e.g. `' + exampleTokens(list) + '` · `all except <tokens>` · `all as-is` (keep every direction).');
|
||||
lines.push('Items marked auto take their recommendation if unanswered; approval items stay pending until answered. Bare yes/no is not accepted.');
|
||||
for (const held of list.items.filter(heldFromAll)) lines.push(`\`all\` does not answer ${held.id} (recommended ${held.recommended} changes the plan); reply ${held.options.map(o => `${held.id}${o.key}`).join(' or ')} yourself.`);
|
||||
lines.push(`Reply binds to gate_rev=${list.gate_rev}. \`all\` resolves to: ${JSON.stringify({ gate_rev: list.gate_rev, choices: resolveAll(list) })}`);
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
@@ -156,7 +173,7 @@ export function parseReply(reply: string, list: GateList, gateRev?: number, prio
|
||||
if (chosen !== undefined) choices.push({ id: item.id, chosen, kind: item.kind, how: how(item, chosen) });
|
||||
else if (earlier !== undefined && item.options.some(o => o.key === earlier)) choices.push({ id: item.id, chosen: earlier, kind: item.kind, how: how(item, earlier), prior: true });
|
||||
else if (asIs) { const keep = keepDirection(item); choices.push({ id: item.id, chosen: keep, kind: item.kind, how: how(item, keep) }); }
|
||||
else if (all) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' });
|
||||
else if (all && !heldFromAll(item)) choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'recommended' });
|
||||
else if (item.kind === 'auto') choices.push({ id: item.id, chosen: item.recommended, kind: item.kind, how: 'auto_default' });
|
||||
else pending.push(item.id);
|
||||
}
|
||||
@@ -185,14 +202,17 @@ export function decisionRows(list: GateList, extra: Partial<DecisionRow> = {}):
|
||||
}));
|
||||
}
|
||||
|
||||
export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string): DecisionRow[] {
|
||||
/** `--by` values: the owner typing tokens, or an agent relaying or deciding them. */
|
||||
export const ANSWERED_BY_RE = /^(human|agent:[A-Za-z0-9_.:-]+)$/;
|
||||
|
||||
export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt: string, answeredBy?: string): DecisionRow[] {
|
||||
if (!parsed.valid) return rows;
|
||||
const byId = new Map(parsed.choices.map(c => [c.id, c]));
|
||||
return rows.map(row => {
|
||||
const choice = byId.get(row.label?.toLowerCase() ?? '') ?? byId.get(row.id.slice(row.run.length + 1).replace(/^r\d+-/, ''));
|
||||
if (!choice || choice.prior || row.gate_rev !== parsed.gate_rev) return row;
|
||||
return {
|
||||
...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply,
|
||||
...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply, ...(answeredBy ? { answered_by: answeredBy } : {}),
|
||||
status: choice.how === 'override' ? 'overridden' : 'approved',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -35,7 +35,8 @@ export interface DecisionRow {
|
||||
schema_version: 1; run: string; id: string; title: string; options: string[]; recommended: string | null;
|
||||
kind: DecisionKind; status: DecisionStatus; gate_rev: number; label?: string; phase?: string;
|
||||
default_if_unanswered?: string | null; chosen?: string | null; answered_at?: string; reply?: string;
|
||||
source?: string; cost?: string;
|
||||
/** Who answered: `human` or `agent:<id>` (required in unattended runs). */
|
||||
answered_by?: string; source?: string; cost?: string;
|
||||
/** Why the recommendation (gate items built by gstack-autoplan), what a user challenge changes, and the carry-forward identity. */
|
||||
recommended_basis?: string; changes?: string; origin?: string; carried_from?: string; chosen_text?: string;
|
||||
}
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ export function renderBrief(inputs: BriefInputs, o: { page?: number; summary?: s
|
||||
'',
|
||||
renderDecisions(inputs, page).trimEnd(),
|
||||
'',
|
||||
`Full review: ${record}${recordNote}. Answer with \`gstack-autoplan answer --out <dir> --gate-rev ${inputs.gate.gate_rev} --reply "<all | d3b uc1a | all except d3b>"\`.`,
|
||||
`Full review: ${record}${recordNote}. Answer with \`gstack-autoplan answer --out <dir> --gate-rev ${inputs.gate.gate_rev} --reply "<all | d3b uc1a | all except d3b>" --by human\`.`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
+2
-2
@@ -177,7 +177,7 @@ function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: stri
|
||||
newVersion = bumped(atBase.version, level);
|
||||
claims = 'ignored (stamp-at-merge)';
|
||||
} else {
|
||||
const r = spawnSync('bun', [path.join(BIN_DIR, 'gstack-next-version'), '--base', o.base, '--bump', level, '--current-version', atBase.version], { cwd: o.repoRoot, encoding: 'utf8', timeout: 120_000, env: o.env ?? process.env });
|
||||
const r = spawnSync(process.execPath, [path.join(BIN_DIR, 'gstack-next-version'), '--base', o.base, '--bump', level, '--current-version', atBase.version], { cwd: o.repoRoot, encoding: 'utf8', timeout: 120_000, env: o.env ?? process.env });
|
||||
let q: any = null;
|
||||
try { q = JSON.parse(r.stdout ?? ''); } catch {}
|
||||
const usable = r.status === 0 && q && typeof q.version === 'string' && parseVersion(q.version) && (q.offline === false || q.fallback === 'git');
|
||||
@@ -344,7 +344,7 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
|
||||
const v = stage('version');
|
||||
if (command) { v.status = 'skipped'; v.detail = 'restamp_command owns the version'; }
|
||||
else {
|
||||
const w = spawnSync('bun', [path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
|
||||
const w = spawnSync(process.execPath, [path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
|
||||
if (w.status !== 0) { v.status = 'failed'; v.detail = (w.stderr ?? '').trim().slice(0, 300); throw new Refusal('RESTAMP_VERSION_SOURCE', `gstack-version-bump write failed: ${v.detail}; nothing written`); }
|
||||
v.status = 'done'; v.detail = `wrote ${plan.newVersion}`;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,11 @@ export const RESULT_CODES = {
|
||||
summary: 'part of the reply matched no gate item or option, so nothing was applied',
|
||||
fix: 'reply with `all`, `all as-is`, `<id><option>` tokens (for example `d3b uc1a`), or `all except <tokens>`; bare yes/no is ambiguous',
|
||||
},
|
||||
ANSWER_BY_REQUIRED: {
|
||||
anchor: 'answer-by-required',
|
||||
summary: 'an unattended gate answer named no valid answerer, so nothing was applied',
|
||||
fix: 'pass `--by human` when the owner typed the tokens, or `--by agent:<thread or id>` when an agent relays or decides; the value lands on every decision row as answered_by',
|
||||
},
|
||||
// PR A (multi-agent wave): gstack-doctor --check, gstack-capy-install, gstack-browser-ensure.
|
||||
COMPONENT_MISSING: {
|
||||
anchor: 'doctor-component-missing',
|
||||
@@ -527,6 +532,11 @@ export const RESULT_CODES = {
|
||||
summary: 'bin/gstack-upgrade changed nothing: the checkout is dirty, diverged, pinned (detached or off the remote default branch), not a gstack checkout, or the incoming release holds',
|
||||
fix: 'resolve the named cause (commit or move the listed changes, check out the default branch), then re-run bin/gstack-upgrade; it never stashes or resets',
|
||||
},
|
||||
UPGRADE_BUSY: {
|
||||
anchor: 'upgrade-busy',
|
||||
summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), so moving the checkout would change gstack under it',
|
||||
fix: 'wait for those sessions to finish (bin/gstack-lease list names each pid and age), then re-run bin/gstack-upgrade; GSTACK_UPGRADE_FORCE=1 overrides when you know they are idle',
|
||||
},
|
||||
UPGRADE_FETCH_FAILED: {
|
||||
anchor: 'upgrade-fetch-failed',
|
||||
summary: 'bin/gstack-upgrade could not fetch the remote default branch, so it cannot tell whether the checkout is current',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.100",
|
||||
"version": "1.91.101",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+5
-4
@@ -701,17 +701,18 @@ Sanitize every query before it leaves the machine: strip hostnames, IPs, file pa
|
||||
|
||||
## Step 4: Critical pass (core review)
|
||||
|
||||
> **STOP.** Before any probe, including plan checks, complete the ordered scope/method Reads below in earlier responses. Never batch a probe (capture included) with its prerequisite Read; templates cannot replace Reads.
|
||||
> **STOP.** Before any probe, including plan checks, complete the ordered scope/method Reads in earlier responses. Never batch a probe (capture, guard start, plan check) with a Read.
|
||||
Step 4 is read-only: defer charters, setup and probes to Step 4.7.
|
||||
|
||||
From the installed /review SKILL.md's directory, choose one path:
|
||||
1. From the installed /review SKILL.md's directory, choose one path:
|
||||
- If the caller directory is `review`, Read `../qa/sections/exploratory.md` in full.
|
||||
- If the caller directory is prefixed `gstack-review`, use `../gstack-qa/sections/exploratory.md` instead and read it in full.
|
||||
- If neither layout applies, report an unresolved QA installation as a setup blocker; do not guess another path.
|
||||
Use this host's installation, never the product tree. If missing or unreadable, report a QA setup blocker and its affected probes as blocked; continue other safe probes (independent functional/static checks). Missing/unreadable assets block required QA.
|
||||
After exploratory returns, Read scope then selected methods in order; it is only the entrypoint.
|
||||
2. Then Read QA's `sections/scope.md` in full; pick surfaces.
|
||||
3. Then Read each listed method in full; the entrypoint alone completes nothing.
|
||||
|
||||
QA's `sections/...` and `templates/...` paths resolve from installed QA SKILL.md, not the caller or product directory.
|
||||
QA's `sections/...` and `templates/...` paths resolve from installed QA SKILL.md, not caller/product.
|
||||
|
||||
Apply both checklist passes in order: CRITICAL, then INFORMATIONAL. Respect its suppressions.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"test/helpers/e2e-helpers.ts": {"blob":"2d4a2a53063cfc1f003db366d2c861ab1e379e6e","since":4},
|
||||
"test/helpers/eval-budgets.ts": {"blob":"667d0e09b20fe5d7366f57b77b5fb9b04baa37ef","since":4},
|
||||
"test/helpers/eval-store.ts": {"blob":"609c6d099108ff2696eb014b47aa318a618dd2af","since":4},
|
||||
"test/helpers/hermetic-env.ts": {"blob":"6473f78285384f4420f48cbe5928658919078e0b","since":4},
|
||||
"test/helpers/hermetic-env.ts": {"blob":"cc072895461dc52ea317d1beff72121342420d30","since":4,"nonBehavioral":"hermeticChildEnv adds GSTACK_LEASE_DIR=<runRoot>/gstack-leases so a spawned gstack bin writes its workflow lease under the hermetic run root instead of the developer checkout's .git; the lease is read only by gstack-upgrade, which no eval case runs, so no verdict can change"},
|
||||
"test/helpers/llm-judge.ts": {"blob":"54e3306b494fb5d7f132ccdaaae5c680465028f9","since":4},
|
||||
"test/helpers/paid-test-set.ts": {"blob":"ff9bfe43c43ec2bdd11114f7b8ec7c5736116f75","since":4},
|
||||
"test/helpers/pty-screen.ts": {"blob":"4f77569f9ce11f9cee9f347216e468cd7c956820","since":4},
|
||||
|
||||
@@ -228,12 +228,13 @@ ${setup ? 'Read `sections/browser-setup.md` in full unless already completed;\n'
|
||||
|
||||
export function generateQAReviewPreflight(ctx: TemplateContext): string {
|
||||
sectionPath(ctx, 'qa', 'exploratory');
|
||||
return `> **STOP.** Before any probe, including plan checks, complete the ordered scope/method Reads below in earlier responses. Never batch a probe (capture included) with its prerequisite Read; templates cannot replace Reads.
|
||||
return `> **STOP.** Before any probe, including plan checks, complete the ordered scope/method Reads in earlier responses. Never batch a probe (capture, guard start, plan check) with a Read.
|
||||
${ctx.skillName === 'review' ? 'Step 4 is read-only: defer charters, setup and probes to Step 4.7.\n' : ''}
|
||||
{{QA_RESOURCE:exploratory}}
|
||||
After exploratory returns, Read scope then selected methods in order; it is only the entrypoint.
|
||||
1. {{QA_RESOURCE:exploratory}}
|
||||
2. Then Read QA's \`sections/scope.md\` in full; pick surfaces.
|
||||
3. Then Read each listed method in full; the entrypoint alone completes nothing.
|
||||
|
||||
QA's \`sections/...\` and \`templates/...\` paths resolve from installed QA SKILL.md, not the caller or product directory.`;
|
||||
QA's \`sections/...\` and \`templates/...\` paths resolve from installed QA SKILL.md, not caller/product.`;
|
||||
}
|
||||
|
||||
export function generateQAReview(ctx: TemplateContext): string {
|
||||
|
||||
@@ -531,12 +531,13 @@ Never overwrite another run's reports. Batch only independent Reads.
|
||||
|
||||
**1. Load methods before any QA or explicit-verification probe.**
|
||||
|
||||
> **STOP.** Before any probe, including plan checks, complete the ordered scope/method Reads below in earlier responses. Never batch a probe (capture included) with its prerequisite Read; templates cannot replace Reads.
|
||||
> **STOP.** Before any probe, including plan checks, complete the ordered scope/method Reads in earlier responses. Never batch a probe (capture, guard start, plan check) with a Read.
|
||||
|
||||
From the installed /ship SKILL.md's directory, Read `../qa/sections/exploratory.md` in full. If the caller directory is prefixed `gstack-ship`, use `../gstack-qa/sections/exploratory.md` instead. Use this host's installation, never the product tree. If missing or unreadable, report a QA setup blocker and its affected probes as blocked; continue other safe probes (independent functional/static checks). Missing/unreadable assets block required QA.
|
||||
After exploratory returns, Read scope then selected methods in order; it is only the entrypoint.
|
||||
1. From the installed /ship SKILL.md's directory, Read `../qa/sections/exploratory.md` in full. If the caller directory is prefixed `gstack-ship`, use `../gstack-qa/sections/exploratory.md` instead. Use this host's installation, never the product tree. If missing or unreadable, report a QA setup blocker and its affected probes as blocked; continue other safe probes (independent functional/static checks). Missing/unreadable assets block required QA.
|
||||
2. Then Read QA's `sections/scope.md` in full; pick surfaces.
|
||||
3. Then Read each listed method in full; the entrypoint alone completes nothing.
|
||||
|
||||
QA's `sections/...` and `templates/...` paths resolve from installed QA SKILL.md, not the caller or product directory.
|
||||
QA's `sections/...` and `templates/...` paths resolve from installed QA SKILL.md, not caller/product.
|
||||
|
||||
**2. List required checks.**
|
||||
Run the shared preflight; start its smoke guard once. Guard every smoke probe. For browsers, Read QA's `sections/browser-setup.md` for report-only rules.
|
||||
|
||||
@@ -34,6 +34,11 @@ for (const name of ['GSTACK_STATE_ROOT', 'GSTACK_STATE_DIR']) {
|
||||
process.stderr.write(`test-setup: stripped inherited ${name}; tests isolate state via GSTACK_HOME\n`);
|
||||
}
|
||||
process.env.GSTACK_TEST_LEGACY_ROOT = path.join(os.tmpdir(), `gstack-test-legacy-root-${process.pid}-${Date.now()}-unused`);
|
||||
// Workflow leases (bin/gstack-lease) default to the installation's git dir;
|
||||
// for the dev checkout under test that is this repo's .git, and a live test
|
||||
// runner's lease would make `bin/gstack-upgrade` here refuse UPGRADE_BUSY.
|
||||
// Every spawned bin inherits a per-process scratch lease directory instead.
|
||||
process.env.GSTACK_LEASE_DIR ??= path.join(os.tmpdir(), `gstack-test-leases-${process.pid}`);
|
||||
|
||||
// Narrowly restore PATH after every test. Defends against the recurring
|
||||
// pollution class where one test sets `process.env.PATH = '/test/bin:/usr/bin'`
|
||||
|
||||
@@ -29,7 +29,7 @@ function withFixture(check: (fixture: {
|
||||
fs.writeFileSync(path.join(home, '.gstack', 'config.yaml'), 'operator sentinel\n');
|
||||
const env = {
|
||||
PATH: process.env.PATH!, HOME: home, TMPDIR: tmp, TMP: tmp, TEMP: tmp,
|
||||
GSTACK_HOME: state, CONDUCTOR_WORKSPACE_PATH: project,
|
||||
GSTACK_HOME: state, GSTACK_LEASE_DIR: path.join(tmp, 'gstack-leases'), CONDUCTOR_WORKSPACE_PATH: project,
|
||||
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig'),
|
||||
};
|
||||
execFileSync('git', ['init', '-b', 'main'], { cwd: project, env, stdio: 'pipe', timeout: 10_000 });
|
||||
|
||||
@@ -71,23 +71,23 @@ describe('answer', () => {
|
||||
test('stale, unparsed and conflicting replies write nothing; a reply that leaves approval items pending keeps the gate', () => {
|
||||
const out = gatedRun('stale');
|
||||
const before = { run: fs.readFileSync(path.join(out, 'run.json'), 'utf8'), decisions: fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8'), journal: journal(out).length };
|
||||
const stale = autoplan(['answer', '--out', out, '--gate-rev', '7', '--reply', 'all']);
|
||||
const stale = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '7', '--reply', 'all']);
|
||||
expect(stale.status).toBe(3);
|
||||
expect(stale.stderr).toMatch(/\(GATE_REV_STALE\)$/m);
|
||||
expect(stale.stdout).toContain('GSTACK_RESULT: skill=autoplan status=refused');
|
||||
const unparsed = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'd1b approve it']);
|
||||
const unparsed = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'd1b approve it']);
|
||||
expect(unparsed.status).toBe(1);
|
||||
expect(unparsed.stderr).toMatch(/unparsed: approve, it.*\(GATE_REPLY_UNPARSED\)$/m);
|
||||
const conflicting = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'd1a d1b p1a']);
|
||||
const conflicting = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'd1a d1b p1a']);
|
||||
expect(conflicting.status).toBe(1);
|
||||
expect(conflicting.stderr).toMatch(/conflicts with d1a.*\(GATE_REPLY_UNPARSED\)$/m);
|
||||
const missingRev = autoplan(['answer', '--out', out, '--reply', 'all']);
|
||||
const missingRev = autoplan(['answer', '--by', 'human', '--out', out, '--reply', 'all']);
|
||||
expect(missingRev.status).toBe(3);
|
||||
expect(fs.readFileSync(path.join(out, 'run.json'), 'utf8')).toBe(before.run);
|
||||
expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before.decisions);
|
||||
expect(journal(out).length).toBe(before.journal);
|
||||
// partial: only the auto item answered; approval items stay pending; nothing reviewed changes
|
||||
const partial = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'd1a', '--json']);
|
||||
const partial = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'd1a', '--json']);
|
||||
expect(partial.status, partial.stderr).toBe(0);
|
||||
expect(JSON.parse(partial.stdout)).toMatchObject({ status: 'gate_pending', outcome: 'gate_pending', pending: ['uc1', 'uc2', 'p1'] });
|
||||
const rows = decisions(out);
|
||||
@@ -98,7 +98,7 @@ describe('answer', () => {
|
||||
test('`all` finalizes without another paid phase: status complete, decisions approved, no phase reopened', () => {
|
||||
const out = gatedRun('approve');
|
||||
const counter = readRun(out).attempt_counter;
|
||||
const r = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all']);
|
||||
const r = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all']);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toMatch(/^APPROVED: gate_rev=1 as-is$/m);
|
||||
expect(r.stdout).toContain('GSTACK_RESULT: skill=autoplan status=complete run=' + out);
|
||||
@@ -114,7 +114,7 @@ describe('answer', () => {
|
||||
|
||||
test('a non-recommended choice on a ceo item reopens ceo and eng only, archives their bound files, and the next gate is gate_rev 2', () => {
|
||||
const out = gatedRun('reopen');
|
||||
const r = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all except d1b', '--json']);
|
||||
const r = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except d1b', '--json']);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
const runId = readRun(out).run;
|
||||
expect(JSON.parse(r.stdout)).toMatchObject({ status: 'running', outcome: 'reopened', reopened: ['ceo', 'eng'], amendments: [`${runId}-d1`] });
|
||||
@@ -142,10 +142,10 @@ describe('answer', () => {
|
||||
const rows = decisions(out);
|
||||
expect(rows.filter((x: any) => x.gate_rev === 1)).toHaveLength(4);
|
||||
expect(rows.filter((x: any) => x.gate_rev === 2 && x.status === 'pending').length).toBeGreaterThan(0);
|
||||
const staleAgain = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all']);
|
||||
const staleAgain = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all']);
|
||||
expect(staleAgain.status).toBe(3);
|
||||
// closing gate_rev 2 exports a run that validates: rev-2 rows get their own ids beside rev 1's
|
||||
const approve = autoplan(['answer', '--out', out, '--gate-rev', '2', '--reply', 'all']);
|
||||
const approve = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '2', '--reply', 'all']);
|
||||
expect(approve.stdout).toContain('GSTACK_RESULT: skill=autoplan status=complete run=' + out);
|
||||
expect(autoplan(['export', '--out', out]).status).toBe(0);
|
||||
const ids = decisions(out).map((x: any) => x.id);
|
||||
@@ -162,7 +162,7 @@ describe('answer', () => {
|
||||
|
||||
test('an eng-only override reopens eng alone; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => {
|
||||
const out = gatedRun('eng-only');
|
||||
const engOnly = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']);
|
||||
const engOnly = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '1', '--reply', 'all except uc2b', '--json']);
|
||||
const runId = readRun(out).run;
|
||||
expect(JSON.parse(engOnly.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['eng'], amendments: [`${runId}-uc2`] });
|
||||
expect(readRun(out).phases.ceo.closed_at).toBeTruthy();
|
||||
@@ -173,13 +173,13 @@ describe('answer', () => {
|
||||
expect(runPhase(out, 'ceo').outside.stdout).toMatch(/PHASE_CLOSED: ceo/);
|
||||
const eng = runPhase(out, 'eng');
|
||||
expect(eng.outside.stdout).toContain('status=gate_pending');
|
||||
const interrogate = autoplan(['answer', '--out', out, '--gate-rev', '2', '--reply', 'all except p1c', '--json']);
|
||||
const interrogate = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '2', '--reply', 'all except p1c', '--json']);
|
||||
expect(JSON.parse(interrogate.stdout)).toMatchObject({ status: 'gate_pending', outcome: 'gate_pending' });
|
||||
const revise = autoplan(['answer', '--out', out, '--gate-rev', '2', '--reply', 'all except p1d', '--json']);
|
||||
const revise = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '2', '--reply', 'all except p1d', '--json']);
|
||||
expect(JSON.parse(revise.stdout)).toMatchObject({ outcome: 'reopened', reopened: ['ceo', 'eng'] });
|
||||
runPhase(out, 'ceo');
|
||||
runPhase(out, 'eng');
|
||||
const reject = autoplan(['answer', '--out', out, '--gate-rev', '3', '--reply', 'all except p1e']);
|
||||
const reject = autoplan(['answer', '--by', 'human', '--out', out, '--gate-rev', '3', '--reply', 'all except p1e']);
|
||||
expect(reject.status).toBe(0);
|
||||
expect(reject.stdout).toContain('GSTACK_RESULT: skill=autoplan status=incomplete run=' + out);
|
||||
expect(readRun(out).deviations).toContain('plan rejected by the owner at gate_rev 3');
|
||||
|
||||
@@ -55,11 +55,11 @@ describe('unattended runs on the Capy host', () => {
|
||||
phaseVia(r, s.out, 'ceo');
|
||||
expect(phaseVia(r, s.out, 'eng').outside.stdout).toContain('status=gate_pending');
|
||||
expectReviewedHashes(s.out);
|
||||
expect(ok(r.ap(['answer', '--out', s.out, '--gate-rev', String(rev), '--reply', 'all except p1d'])).stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\) reason=revise$/m);
|
||||
expect(ok(r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', String(rev), '--reply', 'all except p1d'])).stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\) reason=revise$/m);
|
||||
}
|
||||
phaseVia(r, s.out, 'ceo');
|
||||
phaseVia(r, s.out, 'eng');
|
||||
expect(ok(r.ap(['answer', '--out', s.out, '--gate-rev', '3', '--reply', 'all'])).stdout).toContain('status=complete');
|
||||
expect(ok(r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '3', '--reply', 'all'])).stdout).toContain('status=complete');
|
||||
ok(r.ap(['export', '--out', s.out]));
|
||||
const v = ok(r.validate(s.out));
|
||||
expect(v.stdout).toMatch(/^ARTIFACT_VALID: run=\S+ status=complete/m);
|
||||
@@ -83,7 +83,7 @@ describe('unattended runs on the Capy host', () => {
|
||||
const gated = ok(r.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'outside', '--unavailable', 'no cross-family subagent on this host']));
|
||||
expect(gated.stdout).toMatch(/^PHASE_CLOSED: eng .*coverage=native_only/m);
|
||||
expect(gated.stdout).toContain('status=gate_pending');
|
||||
expect(ok(r.ap(['answer', '--out', s.out, '--gate-rev', '1', '--reply', 'all'])).stdout).toContain('status=complete');
|
||||
expect(ok(r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', 'all'])).stdout).toContain('status=complete');
|
||||
ok(r.ap(['export', '--out', s.out]));
|
||||
expect(ok(r.validate(s.out)).stdout).toMatch(/^ARTIFACT_VALID: run=\S+ status=complete attested=1$/m);
|
||||
expect(fs.readFileSync(path.join(s.out, 'review-record.md'), 'utf8')).toMatch(/attested/);
|
||||
@@ -94,7 +94,7 @@ describe('unattended runs on the Capy host', () => {
|
||||
const s = start();
|
||||
phaseVia(r, s.out, 'ceo');
|
||||
phaseVia(r, s.out, 'eng');
|
||||
ok(r.ap(['answer', '--out', s.out, '--gate-rev', '1', '--reply', 'all except p1d']));
|
||||
ok(r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', 'all except p1d']));
|
||||
phaseVia(r, s.out, 'ceo');
|
||||
phaseVia(r, s.out, 'eng');
|
||||
const file = path.join(s.out, 'decisions.jsonl');
|
||||
@@ -102,7 +102,7 @@ describe('unattended runs on the Capy host', () => {
|
||||
const manifest = readRunJson(s.out);
|
||||
delete manifest.gstack_version;
|
||||
fs.writeFileSync(path.join(s.out, 'run.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
const res = ok(r.ap(['answer', '--out', s.out, '--gate-rev', '2', '--reply', 'all']));
|
||||
const res = ok(r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '2', '--reply', 'all']));
|
||||
expect(res.stdout).toMatch(/^GSTACK_VERSION_CHANGED: run unknown -> /m);
|
||||
expect(res.stdout).toContain('status=complete');
|
||||
ok(r.ap(['export', '--out', s.out]));
|
||||
@@ -149,8 +149,8 @@ describe('conformance: one run through every mechanism', () => {
|
||||
expect(ok(c.ap(['submit', '--out', s.out, '--phase', 'eng', '--voice', 'outside', '--unavailable', 'no outside runner'])).stdout).toContain('status=gate_pending');
|
||||
const accept = JSON.parse(fs.readFileSync(path.join(s.out, 'gate.json'), 'utf8')).items.find((i: any) => i.title.startsWith('Gate the backoff')).id;
|
||||
// partial, then full
|
||||
expect(ok(c.ap(['answer', '--out', s.out, '--gate-rev', '1', '--reply', `${accept}b`])).stdout).toMatch(/^GATE_PENDING: p1/m);
|
||||
const full = ok(c.ap(['answer', '--out', s.out, '--gate-rev', '1', '--reply', 'all']));
|
||||
expect(ok(c.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', `${accept}b`])).stdout).toMatch(/^GATE_PENDING: p1/m);
|
||||
const full = ok(c.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', 'all']));
|
||||
expect(full.stdout).toMatch(new RegExp(`^APPLY: ${run}-${accept} `, 'm'));
|
||||
const pendingNext = c.ap(['next', '--out', s.out]).stderr;
|
||||
expect(pendingNext).toMatch(/\(PLAN_AMEND_PENDING\)$/m);
|
||||
@@ -169,10 +169,10 @@ describe('conformance: one run through every mechanism', () => {
|
||||
for (const voice of ['native', 'outside']) ok(c.ap(['submit', '--out', s.out, '--phase', 'ceo', '--voice', voice, '--result', path.join(s.out, `ceo-${voice}.md`), '--model', voice === 'native' ? 'anthropic/claude-opus-5-5' : 'openai/gpt-6.1-sol']));
|
||||
phaseVia(c, s.out, 'eng');
|
||||
expect(readRunJson(s.out).gate_rev).toBe(2);
|
||||
ok(c.ap(['answer', '--out', s.out, '--gate-rev', '2', '--reply', 'all except p1d']));
|
||||
ok(c.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '2', '--reply', 'all except p1d']));
|
||||
phaseVia(c, s.out, 'ceo');
|
||||
phaseVia(c, s.out, 'eng');
|
||||
expect(ok(c.ap(['answer', '--out', s.out, '--gate-rev', '3', '--reply', 'all'])).stdout).toContain('status=complete');
|
||||
expect(ok(c.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '3', '--reply', 'all'])).stdout).toContain('status=complete');
|
||||
ok(c.ap(['export', '--out', s.out]));
|
||||
expect(ok(c.validate(s.out)).stdout).toMatch(/^ARTIFACT_VALID: run=\S+ status=complete/m);
|
||||
const final = readRunJson(s.out);
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* basis. Reopen and APPLY key on the chosen option's `changes_plan`; accepted
|
||||
* changes are pending amendments that `next` refuses past (PLAN_AMEND_PENDING)
|
||||
* until `revise --incorporates` binds them to a changed plan or `--withdraw`
|
||||
* drops them. `all as-is` keeps every direction. Partial replies resolve
|
||||
* drops them. `all as-is` keeps every direction; `all` leaves an item
|
||||
* recommended `b` pending (the owner's own token changes their direction;
|
||||
* field gate rev 1, d3) and the GATE_PENDING line names the tokens. Partial
|
||||
* replies resolve
|
||||
* against persisted decisions. On reruns a challenge without `changes` is an
|
||||
* agent decision that reopens nothing, rerun prompts carry the decided items,
|
||||
* and answered items from phases that did not rerun carry forward.
|
||||
@@ -45,7 +48,7 @@ function gated(ceo: { native?: unknown[]; outside?: unknown[] }, eng: { native?:
|
||||
}
|
||||
const gate = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8'));
|
||||
const decisions = (out: string) => readJsonl(path.join(out, 'decisions.jsonl'));
|
||||
const answer = (out: string, rev: number, reply: string, json = false) => r.ap(['answer', '--out', out, '--gate-rev', String(rev), '--reply', reply, ...(json ? ['--json'] : [])]);
|
||||
const answer = (out: string, rev: number, reply: string, json = false) => r.ap(['answer', '--by', 'human', '--out', out, '--gate-rev', String(rev), '--reply', reply, ...(json ? ['--json'] : [])]);
|
||||
const v = (dir: string, text: string) => { const f = path.join(dir, `v${++n}.md`); fs.writeFileSync(f, text); return f; };
|
||||
|
||||
describe('changes and user-challenge defaults', () => {
|
||||
@@ -100,9 +103,14 @@ describe('changes and user-challenge defaults', () => {
|
||||
});
|
||||
|
||||
describe('accepted changes are amendments', () => {
|
||||
test('`all` accepts an agreeing change: APPLY, REOPENED, NEXT revise; next refuses until revise --incorporates binds it', () => {
|
||||
test('`all` leaves the agreeing change pending; its own token accepts it: APPLY, REOPENED, NEXT revise; next refuses until revise --incorporates binds it', () => {
|
||||
const g = gated(AGREE, {});
|
||||
const res = answer(g.out, 1, 'all');
|
||||
const held = answer(g.out, 1, 'all');
|
||||
expect(held.status, held.stderr).toBe(0);
|
||||
expect(held.stdout).toMatch(/^GATE_PENDING: uc1 unanswered \(reply with uc1a\|uc1b; all does not answer items recommended b\)$/m);
|
||||
expect(held.stdout).not.toMatch(/^APPLY:/m);
|
||||
expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ status: 'pending' });
|
||||
const res = answer(g.out, 1, 'uc1b');
|
||||
expect(res.status, res.stderr).toBe(0);
|
||||
expect(res.stdout).toMatch(new RegExp(`^APPLY: ${esc(g.run)}-uc1 Ship the backoff behind a flag first .* -> accept the change both reviewers recommend`, 'm'));
|
||||
expect(res.stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\) reason=uc1b$/m);
|
||||
@@ -143,7 +151,7 @@ describe('accepted changes are amendments', () => {
|
||||
expect(asIs.stdout).not.toMatch(/^APPLY:/m);
|
||||
expect(decisions(g.out).find(d => d.label === 'UC1')).toMatchObject({ chosen: 'a', status: 'overridden' });
|
||||
const w = gated(AGREE, {});
|
||||
expect(answer(w.out, 1, 'all').stdout).toMatch(/^APPLY:/m);
|
||||
expect(answer(w.out, 1, 'all except uc1b').stdout).toMatch(/^APPLY:/m);
|
||||
const withdraw = r.ap(['revise', '--out', w.out, '--withdraw', `${w.run}-uc1`]);
|
||||
expect(withdraw.status, withdraw.stderr).toBe(0);
|
||||
expect(readJsonl(path.join(w.out, 'attempts.jsonl')).find(e => e.event === 'amendment_withdrawn')).toMatchObject({ decision: `${w.run}-uc1` });
|
||||
|
||||
@@ -20,7 +20,7 @@ function fixture(plan = original) {
|
||||
// This free test controls unrelated startup side effects, not the seeded repo.
|
||||
writeFileSync(join(state, 'config.yaml'), 'update_check: false\nartifacts_sync: off\ntelemetry: off\n');
|
||||
writeFileSync(join(state, '.proactive-prompted'), '');
|
||||
const env = { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_STATE_ROOT: state };
|
||||
const env = { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, GSTACK_LEASE_DIR: join(state, 'gstack-leases') };
|
||||
const start = () => execFileSync(join(root, 'bin/gstack-skill-start'), ['--skill', 'autoplan'],
|
||||
{ cwd, env, encoding: 'utf8', timeout: 30_000 });
|
||||
const discover = () => execFileSync('bash', ['-c', DESIGN_DOC_DISCOVERY_BLOCK.replaceAll('~/.claude/skills/gstack/bin/', `${root}/bin/`)],
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Field-wave residuals (gstack fix wave review, gate revs 1–2, 2026-10-12/13):
|
||||
* R1 `all` never flips the owner's stated direction: a user-challenge item
|
||||
* whose recommended option changes the plan stays pending under `all` and
|
||||
* is answered only by its own token; the rendering says which ids `all`
|
||||
* leaves open.
|
||||
* R2 every later phase sees its predecessors' consensus: the DX prompt
|
||||
* carries the CEO consensus, not only the Eng prompt.
|
||||
* R3 lib code spawns the Bun that runs it (`process.execPath`), never `bun`
|
||||
* by PATH name, so the snapshot tool and restamp cannot silently run the
|
||||
* Capy image's 1.3.14 when a parent forgot the launch PATH.
|
||||
* R5 gate answers carry provenance: `answer --by <human|agent:<id>>` is
|
||||
* required in an unattended run and lands on every decision row.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { parseReply, renderGate, type GateList } from '../lib/gate-list';
|
||||
import { attemptsOf, autoplan, readRun, review, ROOT, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-residuals-'));
|
||||
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
const keep = { key: 'a', text: 'keep your direction', changes_plan: false };
|
||||
const change = { key: 'b', text: 'accept the change both reviewers recommend', changes_plan: true };
|
||||
const agreed: GateList = {
|
||||
schema_version: 1, run: 'r', gate_rev: 1, items: [
|
||||
{ id: 'uc1', title: 'both voices agree', options: [keep, change], recommended: 'b', kind: 'approval', phase: 'ceo', recommended_basis: 'both voices; remedies shown' },
|
||||
{ id: 'uc2', title: 'one voice', options: [keep, change], recommended: 'a', kind: 'approval', phase: 'ceo' },
|
||||
{ id: 'd1', title: 'auto item', options: [keep, change], recommended: 'a', kind: 'auto', phase: 'ceo' },
|
||||
{ id: 'p1', title: 'Plan approval', options: [{ key: 'a', text: 'approve as-is' }, { key: 'b', text: 'approve with the overrides above' }], recommended: 'a', kind: 'approval' },
|
||||
],
|
||||
};
|
||||
const decisions = (out: string) => fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
||||
|
||||
/** Both voices challenge the same item with the same `changes` and a remedy each: the gate recommends `b`. */
|
||||
function agreedRun(name: string): string {
|
||||
const out = startSmallRun(path.join(TMP, name));
|
||||
const native = JSON.stringify([
|
||||
{ id: 'F1', severity: 'High', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 12, fix: 'print PASS/FAIL per component' },
|
||||
{ id: 'F3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'stack the lanes' }]);
|
||||
const outside = JSON.stringify([
|
||||
{ id: 'O1', severity: 'Critical', title: 'The install check lies by omission', file: 'bin/gstack-doctor', line: 14, fix: 'exit non-zero on any FAIL' },
|
||||
{ id: 'O3', severity: 'High', title: 'Four lanes share result-codes.ts', file: 'lib/result-codes.ts', line: 1, user_challenge: true, changes: 'direction', fix: 'stack the lanes A then B' }]);
|
||||
for (const phase of ['ceo', 'eng']) {
|
||||
expect(autoplan(['next', '--out', out]).status).toBe(0);
|
||||
review(path.join(out, `${phase}-native-prompt.md`), { FIXTURE_FINDINGS: native });
|
||||
review(path.join(out, `${phase}-outside-prompt.md`), { FIXTURE_FINDINGS: outside });
|
||||
const ids = attemptsOf(out, phase);
|
||||
expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'native', '--result', path.join(out, `${phase}-native.md`), '--model', 'anthropic/claude-opus-4-7', '--attempt', ids.native, '--runner', 'host-subagent']).status).toBe(0);
|
||||
expect(autoplan(['submit', '--out', out, '--phase', phase, '--voice', 'outside', '--result', path.join(out, `${phase}-outside.md`), '--model', 'openai/gpt-6-astra', '--attempt', ids.outside, '--runner', 'host-subagent']).status).toBe(0);
|
||||
}
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('R1: `all` never flips the owner\'s stated direction', () => {
|
||||
test('an approval item recommended b (changes the plan) stays pending under `all`; its own token answers it', () => {
|
||||
const r = parseReply('all', agreed);
|
||||
expect(r.valid).toBe(true);
|
||||
expect(r.pending).toEqual(['uc1']);
|
||||
expect(r.choices.map(c => `${c.id}${c.chosen}`)).toEqual(['uc2a', 'd1a', 'p1a']);
|
||||
const explicit = parseReply('all except uc1b', agreed);
|
||||
expect(explicit.pending).toEqual([]);
|
||||
expect(explicit.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'b', how: 'recommended' });
|
||||
const kept = parseReply('all except uc1a', agreed);
|
||||
expect(kept.choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a', how: 'override' });
|
||||
expect(parseReply('all as-is', agreed).choices.find(c => c.id === 'uc1')).toMatchObject({ chosen: 'a' });
|
||||
});
|
||||
|
||||
test('the rendering names the ids `all` leaves open and their exact tokens', () => {
|
||||
const text = renderGate(agreed);
|
||||
expect(text).toMatch(/`all` does not answer uc1 \(recommended b changes the plan\); reply uc1a or uc1b/);
|
||||
expect(text).toMatch(/"choices":\{"uc2":"a","d1":"a","p1":"a"\}/);
|
||||
});
|
||||
|
||||
test('end to end: both voices agree, the gate recommends b, and `all` keeps the gate pending on that item', () => {
|
||||
const out = agreedRun('agreed');
|
||||
const gate = JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')) as GateList;
|
||||
const uc = gate.items.filter(i => i.id.startsWith('uc'));
|
||||
expect(uc.length).toBeGreaterThan(0);
|
||||
expect(uc.every(i => i.recommended === 'b')).toBe(true);
|
||||
const all = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all', '--by', 'human']);
|
||||
expect(all.status, all.stderr).toBe(0);
|
||||
expect(all.stdout).toMatch(new RegExp(`GATE_PENDING: ${uc.map(i => i.id).join(', ')} unanswered \\(reply with ${uc.map(i => `${i.id}a|${i.id}b`).join(' ')}; all does not answer items recommended b\\)`));
|
||||
expect(readRun(out).status).toBe('gate_pending');
|
||||
const rows = decisions(out);
|
||||
for (const i of uc) expect(rows.find(r => r.label === i.id.toUpperCase()).status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('R5: gate answers carry provenance', () => {
|
||||
test('an unattended answer without --by is refused and writes nothing; --by lands on every answered row', () => {
|
||||
const out = agreedRun('provenance');
|
||||
const before = fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8');
|
||||
const bare = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is']);
|
||||
expect(bare.status).toBe(3);
|
||||
expect(bare.stderr).toMatch(/\(ANSWER_BY_REQUIRED\)/);
|
||||
expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).toBe(before);
|
||||
const bad = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'me']);
|
||||
expect(bad.status).toBe(3);
|
||||
expect(bad.stderr).toMatch(/\(ANSWER_BY_REQUIRED\)/);
|
||||
const ok = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all as-is', '--by', 'agent:GSTA-29']);
|
||||
expect(ok.status, ok.stderr).toBe(0);
|
||||
const answered = decisions(out).filter(r => r.status !== 'pending');
|
||||
expect(answered.length).toBeGreaterThan(0);
|
||||
for (const r of answered) expect(r.answered_by).toBe('agent:GSTA-29');
|
||||
expect(fs.readFileSync(path.join(out, 'review-record.md'), 'utf8')).toMatch(/answered by agent:GSTA-29/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('R2: later phases see their predecessors\' consensus', () => {
|
||||
test('the DX prompt carries the CEO consensus block', () => {
|
||||
const out = startSmallRun(path.join(TMP, 'dx-consensus'), ['--developer-tool']);
|
||||
expect(readRun(out).phase_order).toEqual(['ceo', 'dx', 'eng']);
|
||||
runPhase(out, 'ceo');
|
||||
expect(autoplan(['next', '--out', out]).status).toBe(0);
|
||||
const dx = fs.readFileSync(path.join(out, 'dx-native-prompt.md'), 'utf8');
|
||||
expect(dx).toContain('PRIOR-PHASE CONSENSUS');
|
||||
expect(dx).toContain('### CEO');
|
||||
expect(fs.readFileSync(path.join(out, 'dx-outside-prompt.md'), 'utf8')).toContain('### CEO');
|
||||
});
|
||||
});
|
||||
|
||||
describe('R3: lib code spawns the Bun that runs it', () => {
|
||||
/** The admission probe measures the Bun a bare `bun` would run (that is its job); nothing else may. */
|
||||
const EXEMPT: Record<string, string> = { 'lib/autoplan-admission.ts': 'probes the PATH Bun for the install verdict by design' };
|
||||
test('no spawn of `bun` by PATH name under lib/ or bin/*.ts', () => {
|
||||
const offenders: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); continue; }
|
||||
if (!p.endsWith('.ts') || p.endsWith('.test.ts')) continue;
|
||||
const text = fs.readFileSync(p, 'utf8');
|
||||
if (EXEMPT[path.relative(ROOT, p)]) continue;
|
||||
text.split('\n').forEach((line, i) => { if (/\bspawn(?:Sync)?\(\s*['"]bun['"]/.test(line)) offenders.push(`${path.relative(ROOT, p)}:${i + 1}`); });
|
||||
}
|
||||
};
|
||||
walk(path.join(ROOT, 'lib'));
|
||||
for (const e of fs.readdirSync(path.join(ROOT, 'bin'))) if (e.endsWith('.ts')) {
|
||||
const p = path.join(ROOT, 'bin', e);
|
||||
fs.readFileSync(p, 'utf8').split('\n').forEach((line, i) => { if (/\bspawn(?:Sync)?\(\s*['"]bun['"]/.test(line)) offenders.push(`bin/${e}:${i + 1}`); });
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -150,7 +150,7 @@ describe('drift hints', () => {
|
||||
test('answer prints PLAN_SOURCE_CHANGED when a reply reopens phases after the source plan changed', () => {
|
||||
const s = gated();
|
||||
fs.writeFileSync(s.plan, V2);
|
||||
const res = r.ap(['answer', '--out', s.out, '--gate-rev', '1', '--reply', 'all except p1d']);
|
||||
const res = r.ap(['answer', '--by', 'human', '--out', s.out, '--gate-rev', '1', '--reply', 'all except p1d']);
|
||||
expect(res.stdout).toMatch(/^REOPENED: ceo,eng \(Eng last\)/m);
|
||||
expect(res.stdout).toMatch(/^PLAN_SOURCE_CHANGED: .* differs from the reviewed plan; run gstack-autoplan revise --out \S+ --plan \S+$/m);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,19 @@ const STUB_INSTALLER = [
|
||||
'echo "gstack: ok $(cat VERSION) for=autoplan"',
|
||||
].join('\n') + '\n';
|
||||
|
||||
function fixture(opts: { minWrapper?: number } = {}) {
|
||||
/** What bin/gstack-capy-session's self-upgrade runs: bin/gstack-upgrade and its sourced helpers. */
|
||||
const UPGRADE_DEPS = [
|
||||
'bin/gstack-upgrade', 'bin/gstack-bun-version.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh',
|
||||
'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'lib/egress-receipt.ts', 'lib/state-root.ts',
|
||||
'bin/gstack-lease', 'bin/gstack-harness-pid.sh',
|
||||
];
|
||||
|
||||
/**
|
||||
* cloneAt 'A' (default): the clone sits at A (1.0.0), below B's minimum 1.0.1.
|
||||
* cloneAt 'B': the clone sits at B, AT the minimum, and the remote moves on to
|
||||
* C (1.0.2, minimum unchanged): behind origin but not below the floor.
|
||||
*/
|
||||
function fixture(opts: { minWrapper?: number; cloneAt?: 'A' | 'B' } = {}) {
|
||||
const host = capyHost({ systemBun: '1.4.2' });
|
||||
hosts.push(host);
|
||||
const git = (args: string[], cwd: string) => {
|
||||
@@ -68,10 +80,10 @@ function fixture(opts: { minWrapper?: number } = {}) {
|
||||
git(['init', '-q', '--bare', '-b', 'main', bare], host.base);
|
||||
git(['push', '-q', bare, 'main'], seed);
|
||||
const root = path.join(host.base, 'gstack');
|
||||
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
|
||||
if ((opts.cloneAt ?? 'A') === 'A') git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
|
||||
write(path.join(seed, 'VERSION'), '1.0.1\n');
|
||||
write(path.join(seed, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.1\nGSTACK_CAPY_MIN_WRAPPER=${opts.minWrapper ?? 1}\n`);
|
||||
for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh']) {
|
||||
for (const rel of ['capy/skill/gstack/scripts/install.sh', 'capy/skill/gstack/SKILL.md', 'bin/gstack-capy-session', 'bin/gstack-install-verdict.sh', ...UPGRADE_DEPS]) {
|
||||
if (fs.existsSync(path.join(REPO, rel))) write(path.join(seed, rel), fs.readFileSync(path.join(REPO, rel), 'utf8'), 0o755);
|
||||
}
|
||||
write(path.join(seed, 'bin/gstack-capy-install'), STUB_INSTALLER, 0o755);
|
||||
@@ -79,6 +91,14 @@ function fixture(opts: { minWrapper?: number } = {}) {
|
||||
git(['commit', '-qm', 'B'], seed);
|
||||
const b = git(['rev-parse', 'HEAD'], seed);
|
||||
git(['push', '-q', bare, 'main'], seed);
|
||||
let c = '';
|
||||
if (opts.cloneAt === 'B') {
|
||||
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
|
||||
write(path.join(seed, 'VERSION'), '1.0.2\n');
|
||||
git(['commit', '-qam', 'C'], seed);
|
||||
c = git(['rev-parse', 'HEAD'], seed);
|
||||
git(['push', '-q', bare, 'main'], seed);
|
||||
}
|
||||
const env = { ...host.env, GSTACK_CAPY_ROOT: root, GSTACK_CAPY_REPO: `file://${bare}`, WRAP_LOG: log, WRAP_FAIL_ONCE: path.join(host.base, 'fail-once') };
|
||||
const run = (args: string[] = [], extra: Record<string, string> = {}) => {
|
||||
const r = spawnSync('bash', [BOOT, ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 120_000 });
|
||||
@@ -86,7 +106,7 @@ function fixture(opts: { minWrapper?: number } = {}) {
|
||||
return { ...r, lines, last: lines.at(-1) ?? '' };
|
||||
};
|
||||
return {
|
||||
host, root, bare, a, b, run, git: (args: string[]) => git(args, root),
|
||||
host, root, bare, a, b, c, run, git: (args: string[]) => git(args, root),
|
||||
calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
@@ -210,6 +230,67 @@ describe('capy skill bootstrapper (capy/skill/gstack/scripts/install.sh)', () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe('capy skill: always current when online (the minimum is the floor, not the trigger)', () => {
|
||||
test('install mode: a clean checkout at the minimum but behind origin is fast-forwarded by the checkout half and installed from the new revision; the next start is current', () => {
|
||||
const f = fixture({ cloneAt: 'B' });
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
|
||||
const r = f.run(['--for', 'autoplan']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).not.toContain('gstack-capy-skill: upgraded');
|
||||
expect(r.stdout).toContain('gstack-upgrade: upgraded 1.0.1 -> 1.0.2');
|
||||
expect(r.stdout).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c);
|
||||
expect(r.last).toBe('gstack: ok 1.0.2 for=autoplan');
|
||||
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=1 min=1.0.1 from=1.0.1`]);
|
||||
expect(fs.existsSync(path.join(f.root, '.git/gstack-upgrade.pending'))).toBe(false);
|
||||
|
||||
const next = f.run(['--for', 'autoplan']);
|
||||
expect(next.status, next.stdout + next.stderr).toBe(0);
|
||||
expect(next.stdout).toMatch(/^SELF_UPGRADE: none \(current\)$/m);
|
||||
expect(next.stdout).not.toContain('SELF_UPGRADE: done');
|
||||
expect(next.last).toBe('gstack: ok 1.0.2 for=autoplan');
|
||||
expect(f.calls().at(-1)).toBe(`installer --root ${f.root} --for autoplan lock=1 rev=1 min=1.0.1 from=`);
|
||||
});
|
||||
|
||||
test('--check mode never moves a behind checkout: it names the gap and the fast-forwarding command, then certifies the installed revision', () => {
|
||||
const f = fixture({ cloneAt: 'B' });
|
||||
const r = f.run(['--check', '--for', 'autoplan']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toMatch(new RegExp(`^gstack-capy-session: behind origin/main: 1\\.0\\.1 -> 1\\.0\\.2 \\(1 commits\\); fix: bash ${BOOT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} --for autoplan \\(fast-forwards\\)$`, 'm'));
|
||||
expect(r.stdout).not.toContain('SELF_UPGRADE');
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
||||
expect(f.calls()).toEqual([`installer --check --root ${f.root} --for autoplan lock= rev=1 min=1.0.1 from=`]);
|
||||
});
|
||||
|
||||
test('a dirty behind checkout is skipped with gstack-upgrade\'s reason and still ends ok: above the floor, the trailer is the doctor\'s', () => {
|
||||
const f = fixture({ cloneAt: 'B' });
|
||||
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
|
||||
const r = f.run(['--for', 'autoplan']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toMatch(/^SELF_UPGRADE: skipped \(tracked files changed: README\.md.*\)$/m);
|
||||
expect(r.stdout).not.toContain('SELF_UPGRADE: done');
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n');
|
||||
expect(r.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
||||
expect(f.calls()).toEqual([`installer --root ${f.root} --for autoplan lock=1 rev=1 min=1.0.1 from=`]);
|
||||
});
|
||||
|
||||
test('GSTACK_CAPY_SELF_UPGRADE=off and GSTACK_CAPY_AUTO_UPGRADE=0 each disable the fast-forward by name and leave HEAD where it is', () => {
|
||||
const f = fixture({ cloneAt: 'B' });
|
||||
const off = f.run(['--for', 'autoplan'], { GSTACK_CAPY_SELF_UPGRADE: 'off' });
|
||||
expect(off.status, off.stdout + off.stderr).toBe(0);
|
||||
expect(off.stdout).toMatch(/^SELF_UPGRADE: disabled \(GSTACK_CAPY_SELF_UPGRADE\)$/m);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
const auto = f.run(['--for', 'autoplan'], { GSTACK_CAPY_AUTO_UPGRADE: '0' });
|
||||
expect(auto.status, auto.stdout + auto.stderr).toBe(0);
|
||||
expect(auto.stdout).toMatch(/^SELF_UPGRADE: disabled \(GSTACK_CAPY_AUTO_UPGRADE\)$/m);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
expect(auto.last).toBe('gstack: ok 1.0.1 for=autoplan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('capy skill: vendored text and support file', () => {
|
||||
const support = fs.existsSync(path.join(REPO, 'capy/support.env')) ? fs.readFileSync(path.join(REPO, 'capy/support.env'), 'utf8') : '';
|
||||
test('the wrapper revision meets the checkout minimum; GSTACK_MIN_REVISION <= VERSION', () => {
|
||||
|
||||
@@ -384,7 +384,7 @@ describe('CEO finding fixture establishes scope before launch', () => {
|
||||
expect(fs.readFileSync(path.join(cwd, 'CLAUDE.md'), 'utf8')).toContain('Read it before\nchoosing review scope');
|
||||
fs.writeFileSync(path.join(state, 'config.yaml'), 'update_check: false\nrouting_declined: false\n');
|
||||
const output = execFileSync(path.join(ROOT, 'bin', 'gstack-skill-start'), ['--skill', 'plan-ceo-review'], {
|
||||
cwd, env: { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state }, encoding: 'utf8', timeout: 10_000,
|
||||
cwd, env: { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_LEASE_DIR: path.join(state, 'gstack-leases') }, encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
expect(output).toContain('HAS_ROUTING: yes');
|
||||
expect(output).not.toContain('GSTACK_INSTRUCTION_BEGIN: routing-injection');
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* bin/gstack-lease: the workflow lease on a shared gstack installation (field
|
||||
* wave residual R4). Eight Capy subagents share one checkout; each session
|
||||
* start touches `<git dir>/gstack-leases/<harness pid>` (install-scoped, like
|
||||
* gstack-upgrade.pending), and an updater refuses to move the checkout while
|
||||
* a lease whose pid is alive and not in its own ancestry exists. Pinned:
|
||||
* `touch` writes and prunes (older than 120 min), `live` lists live foreign
|
||||
* leases (exit 0) or nothing (exit 1), `list` shows every lease with its age
|
||||
* and alive|dead, and bin/gstack-skill-start touches the lease for the
|
||||
* harness pid it derives. Fixture: a throwaway git checkout, a sleeping child
|
||||
* process as the live pid, an exited process as the dead pid.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawn, spawnSync, type ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const REPO = path.resolve(import.meta.dir, '..');
|
||||
const LEASE = path.join(REPO, 'bin/gstack-lease');
|
||||
const START = path.join(REPO, 'bin/gstack-skill-start');
|
||||
const dirs: string[] = [];
|
||||
const children: ChildProcess[] = [];
|
||||
afterEach(() => {
|
||||
for (const c of children.splice(0)) c.kill('SIGKILL');
|
||||
for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function checkout() {
|
||||
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-')));
|
||||
dirs.push(dir);
|
||||
const r = spawnSync('git', ['init', '-q', dir], { encoding: 'utf8', timeout: 30_000 });
|
||||
if (r.status !== 0) throw new Error(r.stderr);
|
||||
return { dir, leases: path.join(dir, '.git/gstack-leases') };
|
||||
}
|
||||
|
||||
// These cases assert the default lease location, <git dir>/gstack-leases of a
|
||||
// temp checkout; the test preload's GSTACK_LEASE_DIR (which keeps every other
|
||||
// suite out of this repo's .git) would override it, so strip it here.
|
||||
const { GSTACK_LEASE_DIR: _preloadLeaseDir, ...baseEnv } = process.env as Record<string, string>;
|
||||
const run = (args: string[], env: Record<string, string> = {}) =>
|
||||
spawnSync('bash', [LEASE, ...args], { encoding: 'utf8', env: { ...baseEnv, ...env }, timeout: 30_000 });
|
||||
|
||||
/** A child that stays alive for the test: its pid is a live, foreign (non-ancestor) lease holder. */
|
||||
function sleeper(): number {
|
||||
const c = spawn('sleep', ['300'], { stdio: 'ignore' });
|
||||
children.push(c);
|
||||
return c.pid!;
|
||||
}
|
||||
|
||||
/** A pid that no longer exists: a child that has already exited. */
|
||||
function deadPid(): number {
|
||||
const r = spawnSync('true', [], { timeout: 30_000 });
|
||||
return r.pid!;
|
||||
}
|
||||
|
||||
describe('gstack-lease', () => {
|
||||
test('bash -n passes and the bin is executable', () => {
|
||||
expect(fs.existsSync(LEASE), LEASE).toBe(true);
|
||||
expect(fs.statSync(LEASE).mode & 0o111).not.toBe(0);
|
||||
const r = spawnSync('bash', ['-n', LEASE], { encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test('touch writes <git dir>/gstack-leases/<pid> for --pid and prunes leases older than 120 minutes', () => {
|
||||
const { dir, leases } = checkout();
|
||||
fs.mkdirSync(leases, { recursive: true });
|
||||
fs.writeFileSync(path.join(leases, '99999991'), '');
|
||||
const threeHoursAgo = new Date(Date.now() - 3 * 3600_000);
|
||||
fs.utimesSync(path.join(leases, '99999991'), threeHoursAgo, threeHoursAgo);
|
||||
fs.writeFileSync(path.join(leases, '99999992'), '');
|
||||
const r = run(['touch', '--root', dir, '--pid', '4242']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(fs.readdirSync(leases).sort()).toEqual(['4242', '99999992']);
|
||||
});
|
||||
|
||||
test('live: a sleeping child is a live lease (exit 0, pid and age printed); a dead pid alone is none (exit 1)', () => {
|
||||
const { dir } = checkout();
|
||||
const dead = deadPid();
|
||||
expect(run(['touch', '--root', dir, '--pid', String(dead)]).status).toBe(0);
|
||||
const none = run(['live', '--root', dir]);
|
||||
expect(none.status, none.stdout + none.stderr).toBe(1);
|
||||
expect(none.stdout.trim()).toBe('');
|
||||
|
||||
const live = sleeper();
|
||||
expect(run(['touch', '--root', dir, '--pid', String(live)]).status).toBe(0);
|
||||
const some = run(['live', '--root', dir]);
|
||||
expect(some.status, some.stdout + some.stderr).toBe(0);
|
||||
expect(some.stdout.trim().split('\n')).toEqual([expect.stringMatching(new RegExp(`^${live} \\d+m$`))]);
|
||||
|
||||
const list = run(['list', '--root', dir]);
|
||||
expect(list.status).toBe(0);
|
||||
expect(list.stdout).toMatch(new RegExp(`^${live} \\d+m alive$`, 'm'));
|
||||
expect(list.stdout).toMatch(new RegExp(`^${dead} \\d+m dead$`, 'm'));
|
||||
});
|
||||
|
||||
test('live --exclude-ancestry-of <pid> drops a lease held by that pid or one of its ancestors (this test process)', () => {
|
||||
const { dir } = checkout();
|
||||
expect(run(['touch', '--root', dir, '--pid', String(process.pid)]).status).toBe(0);
|
||||
const unfiltered = run(['live', '--root', dir]);
|
||||
expect(unfiltered.status, unfiltered.stdout + unfiltered.stderr).toBe(0);
|
||||
expect(unfiltered.stdout).toMatch(new RegExp(`^${process.pid} \\d+m$`, 'm'));
|
||||
// $$ inside the bin is a child of this bun process, so our own lease is ancestry.
|
||||
const own = spawnSync('bash', ['-c', `"$LEASE" live --root "$ROOT" --exclude-ancestry-of $$`], {
|
||||
encoding: 'utf8', env: { ...baseEnv, LEASE, ROOT: dir }, timeout: 30_000,
|
||||
});
|
||||
expect(own.status, own.stdout + own.stderr).toBe(1);
|
||||
expect(own.stdout.trim()).toBe('');
|
||||
const foreign = sleeper();
|
||||
expect(run(['touch', '--root', dir, '--pid', String(foreign)]).status).toBe(0);
|
||||
const mixed = spawnSync('bash', ['-c', `"$LEASE" live --root "$ROOT" --exclude-ancestry-of $$`], {
|
||||
encoding: 'utf8', env: { ...baseEnv, LEASE, ROOT: dir }, timeout: 30_000,
|
||||
});
|
||||
expect(mixed.status).toBe(0);
|
||||
expect(mixed.stdout.trim().split('\n')).toEqual([expect.stringMatching(new RegExp(`^${foreign} \\d+m$`))]);
|
||||
});
|
||||
|
||||
test('touch without --pid uses the same harness pid gstack-skill-start derives (this process past the tool-call shell)', () => {
|
||||
const { dir, leases } = checkout();
|
||||
const r = spawnSync('bash', ['-c', `"$LEASE" touch --root "$ROOT"; true`], {
|
||||
encoding: 'utf8', env: { ...baseEnv, LEASE, ROOT: dir }, timeout: 30_000,
|
||||
});
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(fs.readdirSync(leases)).toEqual([String(process.pid)]);
|
||||
});
|
||||
|
||||
test('a directory that is not a git checkout fails closed for the caller (exit 2) and writes nothing', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-plain-'));
|
||||
dirs.push(dir);
|
||||
const r = run(['touch', '--root', dir, '--pid', '1']);
|
||||
expect(r.status).toBe(2);
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
expect(run(['live', '--root', dir]).status).toBe(2);
|
||||
});
|
||||
|
||||
test('gstack-skill-start touches the lease for its --parent-pid (GSTACK_LEASE_DIR hermetic), fail-open', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-home-'));
|
||||
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-lease-state-'));
|
||||
dirs.push(home, state);
|
||||
fs.writeFileSync(path.join(state, 'config.yaml'), 'update_check: false\n');
|
||||
const leaseDir = path.join(state, 'leases');
|
||||
const lease = path.join(leaseDir, '424243');
|
||||
try {
|
||||
const r = spawnSync(START, ['--skill', 'testskill', '--parent-pid', '424243'], {
|
||||
encoding: 'utf8', cwd: home, env: { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_LEASE_DIR: leaseDir }, timeout: 30_000,
|
||||
});
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(fs.existsSync(path.join(state, 'sessions', '424243'))).toBe(true);
|
||||
expect(fs.existsSync(lease), lease).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(lease, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ function runStart(args: string[] = [], env: Record<string, string> = {}): string
|
||||
PATH: process.env.PATH!,
|
||||
HOME: tmpHome,
|
||||
GSTACK_HOME: tmpGstackHome,
|
||||
GSTACK_LEASE_DIR: path.join(tmpGstackHome, 'leases'),
|
||||
...env,
|
||||
},
|
||||
});
|
||||
@@ -151,6 +152,7 @@ describe('gstack-skill-start behavior', () => {
|
||||
try {
|
||||
const output = runStart([], {
|
||||
GSTACK_HOME: state,
|
||||
GSTACK_LEASE_DIR: path.join(state, 'leases'),
|
||||
GSTACK_CHECKPOINT_MODE: 'continuous',
|
||||
GSTACK_CHECKPOINT_PUSH: 'true',
|
||||
});
|
||||
@@ -182,7 +184,7 @@ describe('gstack-skill-start behavior', () => {
|
||||
timeout: 30_000,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpHome,
|
||||
env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: tmpGstackHome },
|
||||
env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: tmpGstackHome, GSTACK_LEASE_DIR: path.join(tmpGstackHome, 'leases') },
|
||||
});
|
||||
// The poisoned marker must be neutralized...
|
||||
expect(out).not.toContain('GSTACK_INSTRUCTION_BEGIN: evil');
|
||||
@@ -230,7 +232,7 @@ describe('gstack-skill-start behavior', () => {
|
||||
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-pid-'));
|
||||
fs.writeFileSync(path.join(state, 'config.yaml'), 'update_check: false\n');
|
||||
try {
|
||||
const env = { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: state, START };
|
||||
const env = { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: state, GSTACK_LEASE_DIR: path.join(state, 'leases'), START };
|
||||
for (const command of ['"$START" --skill t; true', 'sh -c \'"$START" --skill t; true\'; true']) {
|
||||
const out = execFileSync('bash', ['-c', command], { timeout: 30_000, encoding: 'utf-8', cwd: tmpHome, env });
|
||||
expect(out).toMatch(new RegExp(`^SESSION_ID: ${process.pid}-`, 'm'));
|
||||
@@ -487,7 +489,7 @@ describe('gstack-skill-start behavior', () => {
|
||||
fs.writeFileSync(path.join(freshGh, 'config.yaml'), 'update_check: false\n');
|
||||
|
||||
const localStart = path.join(projectSkillRoot, 'bin', 'gstack-skill-start');
|
||||
const env = { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: freshGh };
|
||||
const env = { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: freshGh, GSTACK_LEASE_DIR: path.join(freshGh, 'leases') };
|
||||
try {
|
||||
const overlay = execFileSync(localStart, ['--skill', 'testskill'], {
|
||||
timeout: 30_000,
|
||||
@@ -689,7 +691,7 @@ describe('ARTIFACTS_SYNC attention lines (sync status table)', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-sync-home-'));
|
||||
const gh = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-sync-gh-'));
|
||||
const remote = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-sync-remote-'));
|
||||
const env = { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: gh };
|
||||
const env = { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: gh, GSTACK_LEASE_DIR: path.join(gh, 'leases') };
|
||||
const bin = (name: string, args: string[]) =>
|
||||
execFileSync(path.join(ROOT, 'bin', name), args, { timeout: 30_000, encoding: 'utf-8', cwd: home, env, stdio: 'pipe' });
|
||||
const remoteFiles = () => execFileSync('git', ['--git-dir=' + remote, 'ls-tree', '-r', '--name-only', 'main'], { encoding: 'utf-8', timeout: 30_000 });
|
||||
@@ -736,7 +738,7 @@ describe('gstack-skill-end', () => {
|
||||
const out = execFileSync(
|
||||
END,
|
||||
['--skill', 't', '--outcome', 'success', '--session-id', 'sid-1', '--tel-start', String(start)],
|
||||
{ timeout: 30_000, encoding: 'utf-8', cwd: tmpHome, env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: tmpGstackHome } },
|
||||
{ timeout: 30_000, encoding: 'utf-8', cwd: tmpHome, env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: tmpGstackHome, GSTACK_LEASE_DIR: path.join(tmpGstackHome, 'leases') } },
|
||||
);
|
||||
const m = out.match(/SKILL_END: recorded outcome=success duration_s=(\d+)/);
|
||||
expect(m).not.toBeNull();
|
||||
@@ -760,7 +762,7 @@ describe('gstack-skill-end', () => {
|
||||
timeout: 30_000,
|
||||
encoding: 'utf-8',
|
||||
cwd: tmpHome,
|
||||
env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: tmpGstackHome },
|
||||
env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: tmpGstackHome, GSTACK_LEASE_DIR: path.join(tmpGstackHome, 'leases') },
|
||||
});
|
||||
expect(fs.existsSync(pending)).toBe(false);
|
||||
});
|
||||
@@ -797,7 +799,7 @@ describe('gstack-skill-start install admission', () => {
|
||||
};
|
||||
const start = (gh: string, skill: string, env: Record<string, string>, bin = START) => spawnSync(bin, ['--skill', skill], {
|
||||
timeout: 30_000, encoding: 'utf-8', cwd: tmpHome,
|
||||
env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: gh, ...env },
|
||||
env: { PATH: process.env.PATH!, HOME: tmpHome, GSTACK_HOME: gh, GSTACK_LEASE_DIR: path.join(gh, 'leases'), ...env },
|
||||
});
|
||||
|
||||
test('unattended with Bun 1.3.14 first on PATH: the runtime row with its fix, status=refused, exit 3; interactive keeps running', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* on the simulated Capy host (test/helpers/capy-host.ts).
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { spawn, spawnSync, type ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { capyHost, type CapyHost } from './helpers/capy-host';
|
||||
@@ -19,9 +19,21 @@ const REPO = path.resolve(import.meta.dir, '..');
|
||||
const DEPS = [
|
||||
'bin/gstack-upgrade', 'bin/gstack-bun-version.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh',
|
||||
'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'lib/egress-receipt.ts', 'lib/state-root.ts',
|
||||
'bin/gstack-lease', 'bin/gstack-harness-pid.sh', 'bin/gstack-install-verdict.sh',
|
||||
];
|
||||
const hosts: CapyHost[] = [];
|
||||
afterEach(() => { for (const h of hosts.splice(0)) h.cleanup(); });
|
||||
const children: ChildProcess[] = [];
|
||||
afterEach(() => {
|
||||
for (const c of children.splice(0)) c.kill('SIGKILL');
|
||||
for (const h of hosts.splice(0)) h.cleanup();
|
||||
});
|
||||
|
||||
/** A child that stays alive for the test: a live lease holder that is not in gstack-upgrade's ancestry. */
|
||||
function sleeper(): number {
|
||||
const c = spawn('sleep', ['300'], { stdio: 'ignore' });
|
||||
children.push(c);
|
||||
return c.pid!;
|
||||
}
|
||||
|
||||
const write = (file: string, text: string, mode = 0o644) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
@@ -164,6 +176,62 @@ describe('gstack-upgrade', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses UPGRADE_BUSY while a foreign live lease exists, HEAD and bytes unchanged; GSTACK_UPGRADE_FORCE=1 proceeds', () => {
|
||||
const f = fixture();
|
||||
const live = sleeper();
|
||||
const touch = spawnSync('bash', [path.join(f.root, 'bin/gstack-lease'), 'touch', '--root', f.root, '--pid', String(live)], { encoding: 'utf8', env: f.host.env, timeout: 30_000 });
|
||||
expect(touch.status, touch.stdout + touch.stderr).toBe(0);
|
||||
// Our own session's lease (this bun process is an ancestor of the upgrader) never counts.
|
||||
spawnSync('bash', [path.join(f.root, 'bin/gstack-lease'), 'touch', '--root', f.root, '--pid', String(process.pid)], { encoding: 'utf8', env: f.host.env, timeout: 30_000 });
|
||||
const reflog = f.git(['reflog']);
|
||||
const r = f.run();
|
||||
expect(r.status, r.stdout + r.stderr).toBe(3);
|
||||
expect(r.stderr).toContain('(UPGRADE_BUSY)');
|
||||
expect(r.stderr).toContain('nothing was changed');
|
||||
expect(r.stderr).toMatch(new RegExp(`pid ${live} \\(\\d+m\\)`));
|
||||
expect(r.stderr).not.toContain(`pid ${process.pid} `);
|
||||
expect(r.stderr).toContain('wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle');
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
||||
expect(fs.readFileSync(path.join(f.root, 'VERSION'), 'utf8')).toBe('1.0.0\n');
|
||||
expect(f.git(['reflog'])).toBe(reflog);
|
||||
expect(fs.existsSync(path.join(f.root, '.git/gstack-upgrade.pending'))).toBe(false);
|
||||
expect(f.calls()).toEqual([]);
|
||||
const forced = f.run([], { GSTACK_UPGRADE_FORCE: '1' });
|
||||
expect(forced.status, forced.stdout + forced.stderr).toBe(0);
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
expect(f.calls()).toEqual(['setup --refresh-registered']);
|
||||
});
|
||||
|
||||
test('a dead-pid lease does not block', () => {
|
||||
const f = fixture();
|
||||
const dead = spawnSync('true', [], { timeout: 30_000 }).pid!;
|
||||
const touch = spawnSync('bash', [path.join(f.root, 'bin/gstack-lease'), 'touch', '--root', f.root, '--pid', String(dead)], { encoding: 'utf8', env: f.host.env, timeout: 30_000 });
|
||||
expect(touch.status, touch.stdout + touch.stderr).toBe(0);
|
||||
const r = f.run();
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stderr).not.toContain('UPGRADE_BUSY');
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
});
|
||||
|
||||
test('--ff-only-no-reinstall: HEAD moves and the upgraded line prints, no reinstall ran, no pending marker; refusals still hold', () => {
|
||||
const f = fixture();
|
||||
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
|
||||
const dirty = f.run(['--ff-only-no-reinstall']);
|
||||
expect(dirty.status).toBe(3);
|
||||
expect(dirty.stderr).toContain('(UPGRADE_REFUSED)');
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
||||
f.git(['checkout', '-q', '--', 'README.md']);
|
||||
const r = f.run(['--ff-only-no-reinstall']);
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('gstack-upgrade: upgraded 1.0.0 -> 1.0.1');
|
||||
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
||||
expect(f.calls()).toEqual([]);
|
||||
expect(fs.existsSync(path.join(f.root, '.git/gstack-upgrade.pending'))).toBe(false);
|
||||
const again = f.run(['--check']);
|
||||
expect(again.status, again.stdout + again.stderr).toBe(0);
|
||||
expect(again.stdout).toContain('gstack-upgrade: current 1.0.1');
|
||||
});
|
||||
|
||||
test('every bin a trailer or fix line names exists in the repo', () => {
|
||||
const sources = ['bin/gstack-capy-install', 'bin/gstack-doctor-check.sh', 'bin/gstack-skill-end', 'bin/gstack-upgrade', 'docs/capy-install.md',
|
||||
'capy/skill/gstack/SKILL.md', 'capy/skill/gstack/scripts/install.sh'];
|
||||
|
||||
@@ -67,6 +67,7 @@ export function capyHost(opts: CapyHostOptions = {}): CapyHost {
|
||||
PATH: [systemBin, inherited].join(path.delimiter),
|
||||
HOME: home, TMPDIR: base, BUN_INSTALL: path.join(home, '.bun'),
|
||||
GSTACK_HOME: stateRoot, GSTACK_STATE_ROOT: stateRoot, GSTACK_EPHEMERAL: '1',
|
||||
GSTACK_LEASE_DIR: path.join(base, 'gstack-leases'),
|
||||
GIT_CONFIG_GLOBAL: path.join(home, '.gitconfig'), GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.test',
|
||||
GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.test',
|
||||
|
||||
@@ -547,7 +547,7 @@ export function hermeticChildEnv(
|
||||
const dirs = getHermeticDirs();
|
||||
return buildHermeticEnv(
|
||||
process.env,
|
||||
{ CLAUDE_CONFIG_DIR: dirs.configDir, GSTACK_HOME: dirs.gstackHome },
|
||||
{ CLAUDE_CONFIG_DIR: dirs.configDir, GSTACK_HOME: dirs.gstackHome, GSTACK_LEASE_DIR: path.join(dirs.runRoot, 'gstack-leases') },
|
||||
overrides,
|
||||
opts,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user