fix(ship): unattended handoff writes the receipt to a file the parent reads (GSTA-88) (#3409)

Step 18-H's receipt write gains --out .gstack/tmp/ship-receipt.md; skill-start
prints a ship-only UNATTENDED_SHIP line; docs/unattended.md sends the parent to
read --body on that file and to compare head to the branch SHA.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-25 16:22:14 +00:00
1 parent 08301328e9
commit 5ac0f56934
10 files changed
+72 -13

No files matched your search

+13
View File
@@ -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 <n> --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 <n> --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).
+1 -1
View File
@@ -1 +1 @@
1.91.214.0
1.91.215.0
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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=<dir>\`." ;;
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=<outdir>\`, where <outdir> 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 <outdir>." ;;
ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt in the PR body (\`gstack-ship-receipt read --pr <n> --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 <n> --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)." ;;
+8 -3
View File
@@ -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 <n> --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 <n> --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,
+1 -1
View File
@@ -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",
+3 -3
View File
@@ -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 <base> --out .gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <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
+3 -3
View File
@@ -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 <base> --out .gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <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
+26
View File
@@ -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 <n> --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('<base>', '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)', () => {
+14
View File
@@ -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