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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-25 10:24:44 +00:00
1 parent 5278207dbe
commit 7b6d189b67
6 files changed
+70 -6

No files matched your search

+13
View File
@@ -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=<name> status=gate_pending run=<dir>`. 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=<dir>`. `/eval-plan` ends at its spend approval with `GSTACK_RESULT: skill=eval-plan status=gate_pending run=<outdir>`, 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 <n> --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 `<name>`), 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=<name>` 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.
+1 -1
View File
@@ -1 +1 @@
1.91.203.0
1.91.204.0
+1 -1
View File
@@ -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,
+13 -2
View File
@@ -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=<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\`)." ;;
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 <<EOI
You are running unattended: a parent agent started this run and will read its artifacts (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json; see docs/unattended.md), not your prose. Rules: do NOT call AskUserQuestion and do NOT render prose decision briefs. At every question take the option marked (recommended) and append it to decisions.jsonl with kind "auto". A question with no recommendation, any consent, any destructive or irreversible option, and the final approval gate are appended with kind "approval" and status "pending": never choose them. The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=<name> status=gate_pending run=<dir>\`; 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
+1 -1
View File
@@ -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",
+41 -1
View File
@@ -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=<name> status=gate_pending run=<dir>');
expect(out).toContain('GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>');
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=<name>');
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=<dir>`');
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=<outdir>`');
expect(evalPlan).toContain('No helper writes a gate.json');
expect(evalPlan).not.toContain('gate list');
const records: Record<string, string> = {
ship: 'gstack-ship-receipt read --pr <n> --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