From 7b6d189b67ca53454579e8b8683aa68318714509 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 03:24:44 -0700 Subject: [PATCH] fix(skill-start): unattended block promises GSTACK_RESULT only to skills that write it (#3281, GSTA-83) (#3392) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 ++++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-skill-start | 15 ++++++++++-- package.json | 2 +- test/gstack-skill-start.test.ts | 42 ++++++++++++++++++++++++++++++++- 6 files changed, 70 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dbfb39ff..0672eac8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.91.204.0] - 2026-10-25 + +**An unattended run is told to end with `GSTACK_RESULT` only when its skill can write that line.** The unattended block `gstack-skill-start` prints told every unattended skill to log choices in `decisions.jsonl`, write a gate list and end with `GSTACK_RESULT: skill= status=gate_pending run=`. Only `/autoplan` has helpers that write a run directory, so `/ship`, `/review`, `/qa` and the rest were asked to produce files and a result line nothing writes, and a parent that trusted the block would wait for them. `docs/unattended.md` already names which skills write the line and what a parent reads for the others (#3281); the runtime text now says the same thing. + +### Fixed +- **`bin/gstack-skill-start`**: the decision rules in the unattended block are the same for every skill: take the recommended option, leave unrecommended questions, consents, destructive options and the final gate pending, and never approve, publish or push. Only the sentence about the run's record changes by skill. `/autoplan` keeps its run directory, its `decisions.jsonl` rows, the gate list and `GSTACK_RESULT: skill=autoplan status=gate_pending run=`. `/eval-plan` ends at its spend approval with `GSTACK_RESULT: skill=eval-plan status=gate_pending run=`, and the block says no helper writes a `gate.json` for it yet. Every other skill is told it writes no run directory and prints no result line, to stop at its first pending item and list it in its final report, and which record the parent reads: the ship receipt (`gstack-ship-receipt read --pr --json`) for `/ship`, the review-log rows (`gstack-review-read`) for `/review`, the `qa-report-*.md` file for `/qa`, and the skill's own record in `docs/unattended.md` otherwise. + +### Tests +- `test/gstack-skill-start.test.ts` renders the unattended block for `autoplan`, `eval-plan`, `ship`, `review`, `qa` and `investigate`. It checks that each keeps the shared decision rules, that only `autoplan` and `eval-plan` get a result line (with their own skill name, never ``), and that the other four get no result line, gate list, `decisions.jsonl` or `run.json` and name their record. Against the old block it fails, and so does the existing unattended test, which now expects `skill=autoplan`. + +### Gate integrity +- One test expectation changed: the existing unattended test expects `GSTACK_RESULT: skill=autoplan …` instead of the `skill=` placeholder, which is stricter. No threshold, budget, timeout, trial count or case kind changed. + ## [1.91.203.0] - 2026-10-25 **Asking gstack to fix a failure no longer always opens /investigate (#3157, #3158).** The router sent every bug report straight to the full /investigate workflow, even when the user had named the cause or one look at the failing code showed it. Now the router checks first. When the user names a cause and a narrow fix, it makes one read-only check of the evidence. When that confirms the cause, it makes the fix and runs the focused test. When the cause is unknown, it makes one bounded, read-only triage pass over the supplied evidence and the directly implicated test and code, with no reproduction, hypothesis testing or wider search, and fixes the failure directly only when that pass establishes the root cause. Anything still unclear, including a cause the check contradicts, goes to /investigate before any edit, so the root-cause bar holds. A failing test or stack trace no longer counts as proof of its cause. diff --git a/VERSION b/VERSION index 92dd2e049..aa6af587b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.203.0 +1.91.204.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 1d7bd5607..3b92bdfbe 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.203.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.204.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 be14f26e8..680a645b9 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -639,10 +639,21 @@ fi # end interactive-onboarding guard (kind != spawned/unattended) # final gate is written and never approved, nothing is published or synced. # It also carries the host adaptation a harness without a Skill tool, slash # commands or AskUserQuestion needs, printed here so every host's render has it -# without growing any skill's prose. +# without growing any skill's prose. The record sentence is per skill: only +# skills with steps that write GSTACK_RESULT are told to end with it, and the +# rest name the record the parent reads instead (docs/unattended.md, #3281). if [ "$_SESSION_KIND" = "unattended" ]; then + _UA_NONE="This skill writes no run directory and prints no GSTACK_RESULT line, so do not invent either; end at its first pending item and list that item, with every recommended option you took, in your final report." + 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\`)." ;; + 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)." ;; + esac _emit_block unattended-session < status=gate_pending run=\`; you never approve it, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent. Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read. +You are running unattended: a parent agent started this run and reads its record, not your prose (docs/unattended.md names each skill's record). Rules: do NOT call AskUserQuestion and do NOT render prose decision briefs. At every question take the option marked (recommended). A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate stay pending: never choose them. You never approve a gate, never mark a plan APPROVED, never publish, push, sync or send data off this machine, and never record a consent. $_UA_RECORD Skip every onboarding, upgrade, telemetry, routing and lake instruction. Host adaptation: there may be no Skill tool, no slash commands and no hooks here; read the skill file with your file-reading tool, run its bash blocks as written, treat each bash block's printed STATUS lines as the only source of session facts, and write results to files the parent names rather than relying on chat text being read. EOI fi diff --git a/package.json b/package.json index 45c3219e0..563660092 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.203", + "version": "1.91.204", "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/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index df6e4ea5d..17d0528b8 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -348,7 +348,7 @@ describe('gstack-skill-start behavior', () => { expect(out.match(/GUARD_NOT_INSTALLED/g)).toHaveLength(1); const ids = (out.match(/^GSTACK_INSTRUCTION_BEGIN: (\S+)/gm) ?? []).map((h) => h.replace(/^GSTACK_INSTRUCTION_BEGIN: /, '')); expect(ids).toEqual(['unattended-session']); - expect(out).toContain('GSTACK_RESULT: skill= status=gate_pending run='); + expect(out).toContain('GSTACK_RESULT: skill=autoplan status=gate_pending run='); expect(out).toMatch(/never approve/i); expect(out).toMatch(/^FIRST_TASK: $/m); expect(out).not.toContain('.telemetry-prompted'); @@ -378,6 +378,46 @@ describe('gstack-skill-start behavior', () => { } }); + test('unattended block promises GSTACK_RESULT only to skills that write it; the rest name their record (#3281)', () => { + const block = (skill: string) => { + const out = runStart(['--skill', skill], { GSTACK_SESSION_KIND: 'unattended' }); + const m = out.match(/^GSTACK_INSTRUCTION_BEGIN: unattended-session .*\n([\s\S]*?)\nGSTACK_INSTRUCTION_END$/m); + expect(m).not.toBeNull(); + return m![1]; + }; + const blocks = Object.fromEntries(['autoplan', 'eval-plan', 'ship', 'review', 'qa', 'investigate'].map((s) => [s, block(s)])); + for (const text of Object.values(blocks)) { + expect(text).not.toContain('skill='); + expect(text).toMatch(/never choose them/); + expect(text).toMatch(/never approve a gate/); + expect(text).toMatch(/never publish, push, sync or send data off this machine/); + } + const autoplan = blocks.autoplan; + expect(autoplan).toContain('`GSTACK_RESULT: skill=autoplan status=gate_pending run=`'); + expect(autoplan).toContain('gate list (gstack-gate render)'); + expect(autoplan).toMatch(/decisions\.jsonl with kind "auto"/); + expect(autoplan).toContain('gate.json'); + const evalPlan = blocks['eval-plan']; + expect(evalPlan).toContain('`GSTACK_RESULT: skill=eval-plan status=gate_pending run=`'); + expect(evalPlan).toContain('No helper writes a gate.json'); + expect(evalPlan).not.toContain('gate list'); + const records: Record = { + ship: 'gstack-ship-receipt read --pr --json', + review: 'gstack-review-read', + qa: 'qa-report-*.md', + investigate: "this skill's own record (docs/unattended.md)", + }; + for (const [skill, record] of Object.entries(records)) { + const text = blocks[skill]; + expect(text).toContain('writes no run directory and prints no GSTACK_RESULT line'); + expect(text).toContain(record); + expect(text).not.toContain('GSTACK_RESULT: skill='); + expect(text).not.toContain('gate list'); + expect(text).not.toContain('decisions.jsonl'); + expect(text).not.toContain('run.json'); + } + }, 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