mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
1 parent
08301328e9
commit
5ac0f56934
10 files changed
+72
-13
No files matched your search
@@ -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,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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user