diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bc6ef79e..f7d049cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.91.215.0] - 2026-10-25 + +**An unattended `/ship` leaves its receipt in a file the parent can read, and the parent is told to read that file (GSTA-88).** Before, Step 18's handoff section wrote the ship receipt only to stdout, and `docs/unattended.md` told the parent to read it from the PR body with `gstack-ship-receipt read --pr --json`. An unattended run must not push, so there is no PR: the parent got a usage error or `RECEIPT_MISSING`, or, if an older PR existed, read that PR's receipt with exit 0 and a stale `head`. Now the handoff writes `.gstack/tmp/ship-receipt.md`, the parent reads it with `read --body` in the child's checkout, and it compares the receipt's `head` to the branch SHA either way. + +### Fixed +- **`ship/sections/handoff.md.tmpl`**: the receipt write carries `--out .gstack/tmp/ship-receipt.md`. The completion-message step drops `(\`pr: none\`)`, which was wrong (the block omits `pr`; only the `SHIP_RECEIPT:` line shows `pr=-`), and the "nothing precedes the identifiers" clause, which repeated "identifiers first". Ship's skeleton plus sections is 313,151 bytes, 13 below main and under its 313,281 cap; no cap or pin moved. +- **`bin/gstack-skill-start`**: an unattended `/ship` prints one status line, `UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md`. No other skill or session kind prints it, and no instruction block was added. The unattended block's `/ship` record sentence names the file for a run that did not push and the PR body only once a PR exists. +- **`docs/unattended.md`**: for `/ship`, read `gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json` in the child's checkout when the run did not push, use `read --pr --json` only once a PR exists, and in both cases compare the receipt's `head` to the branch SHA, because `read` does not check that the receipt is current. + +### Tests +- `test/gstack-ship-receipt.test.ts` runs the handoff template's write command as written in a fixture repository, then reads the file back with `read --body`: `head` is HEAD, `session_kind` is `unattended` and there is no `pr`. It also checks that the template and the generated section carry `--out` and that `docs/unattended.md` names the same file with `read --body`. +- `test/gstack-skill-start.test.ts` checks that the `UNATTENDED_SHIP` line appears exactly once for an unattended `/ship`, alongside only the `unattended-session` block, and never for `/review`, `/qa`, `/autoplan` or for `/ship` in an interactive, spawned or headless session. + ## [1.91.214.0] - 2026-10-25 **`gstack-brain-sync --publish-removals --yes` no longer prints `list: unbound variable` when it finishes, and it now removes its temp list and releases the sync lock.** The command's EXIT trap named the function's local `$list`. When a publish succeeded, the function returned before the script exited, so the trap ran after `list` had gone out of scope. Under `set -u` it stopped at the unbound variable and never reached the cleanup, which left `brain-sync-removals.*` in `$TMPDIR` and `.brain-sync.lock.d` in `~/.gstack` until a later run cleared the lock as stale (found by GSTA-44). diff --git a/VERSION b/VERSION index d3a2d850d..42f68a055 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.214.0 +1.91.215.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 6042037f3..4784a3a02 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.214.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.215.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, diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 680a645b9..81ccda14d 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -322,6 +322,7 @@ echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE" # GSTACK_SESSION_KIND=spawned override must light this up too. [ "$_SESSION_KIND" = "spawned" ] && echo "SPAWNED_SESSION: true" || true [ "$_SESSION_KIND" = "unattended" ] && echo "UNATTENDED_SESSION: true" || true +[ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md" || true # --------------------------------------------------------------------------- # Artifacts sync (the former "Artifacts Sync (skill start)" fence, verbatim @@ -647,7 +648,7 @@ if [ "$_SESSION_KIND" = "unattended" ]; then case "$SKILL_NAME" in autoplan) _UA_RECORD="This skill writes a run directory through gstack-autoplan (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json, gate.json): append each recommended choice to decisions.jsonl with kind \"auto\" and each pending one with kind \"approval\" and status \"pending\". The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=autoplan status=gate_pending run=\`." ;; eval-plan) _UA_RECORD="This run ENDS at the Step 4 spend approval, left pending, with \`GSTACK_RESULT: skill=eval-plan status=gate_pending run=\`, where is the pilot's --out directory. No helper writes a gate.json for this skill yet, so the parent reads the pilot report and spend ledger in ." ;; - ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt in the PR body (\`gstack-ship-receipt read --pr --json\`)." ;; + ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt from .gstack/tmp/ship-receipt.md when nothing was pushed, and from the PR body (\`gstack-ship-receipt read --pr --json\`) only once a PR exists." ;; review) _UA_RECORD="$_UA_NONE The parent reads the review-log rows (\`gstack-review-read\`)." ;; qa) _UA_RECORD="$_UA_NONE The parent reads the \`qa-report-*.md\` file." ;; *) _UA_RECORD="$_UA_NONE The parent reads this skill's own record (docs/unattended.md)." ;; diff --git a/docs/unattended.md b/docs/unattended.md index b4f1c1fc1..249ff535c 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -88,9 +88,14 @@ Not every workflow writes this line or a run directory today: helper writes its `gate.json` yet (`gstack-gate` renders and parses a gate list; it does not create one). `/cso` skips the shared startup, so these session rules do not reach it at all. Read each skill's own record instead: - the ship receipt in the PR body (`gstack-ship-receipt read --pr --json`) - for `/ship`, the review-log rows (`gstack-review-read`) for `/review`, and the - `qa-report-*.md` file for `/qa`. + the review-log rows (`gstack-review-read`) for `/review`, the + `qa-report-*.md` file for `/qa`, and the ship receipt for `/ship`. When + the run did not push (an unattended run must not), read the receipt file in + the child's checkout with `gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json`; + use `gstack-ship-receipt read --pr --json` only once a PR exists. In both + cases compare the receipt's `head` to the branch SHA (`git rev-parse HEAD` in + the child's checkout): `read` does not check that the receipt is current, so + an older PR's receipt reads as valid. Every workflow command shares one exit table: `0` ok, `1` fail, `2` usage, `3` refused or needs a flag. Error lines end with a result code in parentheses, diff --git a/package.json b/package.json index 045b6c41d..d03901193 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.214", + "version": "1.91.215", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/ship/sections/handoff.md b/ship/sections/handoff.md index 22f915991..046f585e2 100644 --- a/ship/sections/handoff.md +++ b/ship/sections/handoff.md @@ -10,16 +10,16 @@ automation lane, an unattended parent said so). Nothing here publishes. section does, then (with its Issue links flags; ask nothing): ```bash ~/.claude/skills/gstack/bin/gstack-issue-links --base --out .gstack/tmp/issues.json - ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json + ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json --out .gstack/tmp/ship-receipt.md ``` 2. Send the completion message, identifiers first, in this order: the `SHIP_RECEIPT:` - line and receipt block (`pr: none`); `branch:` and `sha:` (`git rev-parse HEAD`), + line and receipt block; `branch:` and `sha:` (`git rev-parse HEAD`), pushed or `not pushed`; `inputs:` the plan or issue, the base SHA and the spend cap; `before/after:` one row per claimed metric, measured on base and on this SHA with the command used; `escalation:` the one condition that stops the merge (a `requires-remote` lane, an open gate finding, an unverified claim) or `none`. `issues:` its lines, unpromoted candidates and lookup failures. - Full thread ids (`jam_…`) cross-thread; nothing precedes the identifiers. + Full thread ids (`jam_…`) cross-thread. 3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied. ### Multi-lane runs diff --git a/ship/sections/handoff.md.tmpl b/ship/sections/handoff.md.tmpl index efdc0937c..3c39b9674 100644 --- a/ship/sections/handoff.md.tmpl +++ b/ship/sections/handoff.md.tmpl @@ -8,16 +8,16 @@ automation lane, an unattended parent said so). Nothing here publishes. section does, then (with its Issue links flags; ask nothing): ```bash ~/.claude/skills/gstack/bin/gstack-issue-links --base --out .gstack/tmp/issues.json - ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json + ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json --out .gstack/tmp/ship-receipt.md ``` 2. Send the completion message, identifiers first, in this order: the `SHIP_RECEIPT:` - line and receipt block (`pr: none`); `branch:` and `sha:` (`git rev-parse HEAD`), + line and receipt block; `branch:` and `sha:` (`git rev-parse HEAD`), pushed or `not pushed`; `inputs:` the plan or issue, the base SHA and the spend cap; `before/after:` one row per claimed metric, measured on base and on this SHA with the command used; `escalation:` the one condition that stops the merge (a `requires-remote` lane, an open gate finding, an unverified claim) or `none`. `issues:` its lines, unpromoted candidates and lookup failures. - Full thread ids (`jam_…`) cross-thread; nothing precedes the identifiers. + Full thread ids (`jam_…`) cross-thread. 3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied. ### Multi-lane runs diff --git a/test/gstack-ship-receipt.test.ts b/test/gstack-ship-receipt.test.ts index 5e5e0e7d4..c345cbbe1 100644 --- a/test/gstack-ship-receipt.test.ts +++ b/test/gstack-ship-receipt.test.ts @@ -119,6 +119,32 @@ describe('gstack-ship-receipt read', () => { expect(runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '9'], env).status).toBe(EXIT.fail); expect(runBin(repo, 'gstack-ship-receipt', ['read']).status).toBe(EXIT.usage); }); + + test('the unattended handoff write lands in the file docs/unattended.md tells the parent to read --body, at HEAD and with no pr (GSTA-88)', () => { + const root = path.resolve(import.meta.dir, '..'); + const tmpl = fs.readFileSync(path.join(root, 'ship/sections/handoff.md.tmpl'), 'utf8'); + const write = tmpl.match(/gstack-ship-receipt (write [^\n`]*)/)![1].trim(); + const out = write.match(/--out (\S+)/)?.[1]; + expect(out).toBe('.gstack/tmp/ship-receipt.md'); + expect(fs.readFileSync(path.join(root, 'ship/sections/handoff.md'), 'utf8')).toContain(write); + const docs = fs.readFileSync(path.join(root, 'docs/unattended.md'), 'utf8').replace(/\s+/g, ' '); + expect(docs).toContain(`gstack-ship-receipt read --body ${out} --json`); + expect(docs).toMatch(/read --pr --json` only once a PR exists/); + + const repo = fixture(); + const env = { GSTACK_SESSION_KIND: 'unattended' }; + fs.mkdirSync(path.join(repo.work, '.gstack/tmp'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, '.gstack/tmp/ship-gate.json'), '{"free":{"pass":1,"fail":0,"skip":0}}'); + expect(runBin(repo, 'gstack-issue-links', ['--base', 'main', '--out', '.gstack/tmp/issues.json'], env).status).toBe(EXIT.ok); + const w = runBin(repo, 'gstack-ship-receipt', write.replace('', 'main').split(' '), env); + expect(w.status).toBe(EXIT.ok); + expect(fs.readFileSync(path.join(repo.work, out!), 'utf8')).toBe(w.out); + const r = runBin(repo, 'gstack-ship-receipt', ['read', '--body', out!, '--json'], env); + expect(r.status).toBe(EXIT.ok); + const receipt = JSON.parse(r.out); + expect(receipt).toMatchObject({ head: repo.git('rev-parse', 'HEAD'), base: 'main', session_kind: 'unattended' }); + expect(receipt).not.toHaveProperty('pr'); + }); }); describe('gstack-ship-receipt census (P7)', () => { diff --git a/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index 17d0528b8..6669cb711 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -418,6 +418,20 @@ describe('gstack-skill-start behavior', () => { } }, 60_000); + test('unattended /ship alone prints the UNATTENDED_SHIP status line naming the receipt file (GSTA-88)', () => { + const line = /^UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at \.gstack\/tmp\/ship-receipt\.md$/gm; + const ship = runStart(['--skill', 'ship'], { GSTACK_SESSION_KIND: 'unattended' }); + expect(ship.match(line)).toHaveLength(1); + expect((ship.match(/^GSTACK_INSTRUCTION_BEGIN: (\S+)/gm) ?? []).map((h) => h.replace(/^GSTACK_INSTRUCTION_BEGIN: /, ''))).toEqual(['unattended-session']); + expect(ship).toContain('.gstack/tmp/ship-receipt.md when nothing was pushed'); + for (const skill of ['review', 'qa', 'autoplan']) { + expect(runStart(['--skill', skill], { GSTACK_SESSION_KIND: 'unattended' })).not.toContain('UNATTENDED_SHIP'); + } + for (const kind of ['interactive', 'spawned', 'headless']) { + expect(runStart(['--skill', 'ship'], { GSTACK_SESSION_KIND: kind })).not.toContain('UNATTENDED_SHIP'); + } + }, 60_000); + test('unattended session makes no egress call even with telemetry and artifacts sync enabled and a queue pending (plan B1)', () => { // Shadow bin: every helper is the real one except the egress sinks, which // record their invocation. Bins resolve siblings through dirname "$0", so a