diff --git a/.github/workflows/pr-title-sync.yml b/.github/workflows/pr-title-sync.yml index 6f5b3d3e5..4f94d4db9 100644 --- a/.github/workflows/pr-title-sync.yml +++ b/.github/workflows/pr-title-sync.yml @@ -1,7 +1,25 @@ name: PR Title Sync +# WHY pull_request_target (not pull_request): the default GITHUB_TOKEN is +# READ-ONLY on fork PRs under `pull_request`, so the title-sync backstop could +# never `gh pr edit` a fork/agent PR. `pull_request_target` runs in the base-repo +# context with a write token, which fixes fork coverage. +# +# WHY this is SAFE (pull_request_target is the most dangerous trigger): +# - We check out the BASE repo (no `ref:`), so the only code we execute is +# trusted base-repo infra (bin/gstack-pr-title-rewrite.sh). We NEVER check +# out or run PR-head/fork code. +# - Every attacker-controlled PR field (title, head repo, head sha) arrives via +# `env:` and is referenced as a shell-quoted "$VAR". We NEVER inline a +# `${{ github.event.pull_request.* }}` expression inside the run: script +# (that would execute a crafted title as shell). +# - The PR-head VERSION is read as DATA via the API (raw media type), from the +# head repo at the head sha — never by checking out the head. +# test/pr-title-sync-workflow-safety.test.ts is the static tripwire for all of +# the above and fails CI if any of it regresses. + on: - pull_request: + pull_request_target: types: [opened, synchronize, edited] paths: - 'VERSION' @@ -19,25 +37,62 @@ jobs: pull-requests: write if: github.actor != 'github-actions[bot]' steps: - - name: Checkout PR head + # Base repo only — trusted infra (the rewrite helper). No PR-head checkout. + - name: Checkout base repo (trusted) uses: actions/checkout@v4 with: fetch-depth: 1 - ref: ${{ github.event.pull_request.head.sha }} - name: Rewrite PR title to match VERSION env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUM: ${{ github.event.pull_request.number }} + # Attacker-controlled on fork PRs — env-only, never inlined into run:. OLD_TITLE: ${{ github.event.pull_request.title }} + BASE_REPO: ${{ github.repository }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail chmod +x ./bin/gstack-pr-title-rewrite.sh - VERSION=$(cat VERSION | tr -d '[:space:]') - NEW_TITLE=$(./bin/gstack-pr-title-rewrite.sh "$VERSION" "$OLD_TITLE") - if [ "$NEW_TITLE" = "$OLD_TITLE" ]; then - echo "Title already correct; no change." + + if [ "$HEAD_REPO" = "$BASE_REPO" ]; then IS_FORK=0; else IS_FORK=1; fi + + # Read the PR-head VERSION as data (raw bytes), from the head repo at + # the head sha. Guard the assignment itself: under `set -e` a bare + # `VERSION=$(...)` would abort the step before any later [ -z ] check. + if ! VERSION=$(gh api -H "Accept: application/vnd.github.raw" \ + "repos/$HEAD_REPO/contents/VERSION?ref=$HEAD_SHA" 2>/dev/null | tr -d '[:space:]'); then + VERSION="" + fi + + if [ -z "$VERSION" ]; then + # Same-repo read failure should never happen — fail loudly so we + # notice. A fork miss (public-contents quirk, private fork) is a + # convenience gap, not a gate — warn and skip so the check stays green. + if [ "$IS_FORK" = "0" ]; then + echo "::error::Could not read VERSION from same-repo PR head ($HEAD_SHA)." + exit 1 + fi + echo "::warning::Could not read VERSION from fork $HEAD_REPO ($HEAD_SHA); skipping title sync." + exit 0 + fi + + # The helper rejects a malformed VERSION (exit 2). Same policy: loud for + # same-repo, soft for forks. Never echo the raw (attacker-controlled) + # title — Actions still parses ::workflow-command:: from stdout. + if ! NEW_TITLE=$(./bin/gstack-pr-title-rewrite.sh "$VERSION" "$OLD_TITLE"); then + if [ "$IS_FORK" = "0" ]; then + echo "::error::Could not compute title for VERSION '$VERSION' on PR #$PR_NUM." + exit 1 + fi + echo "::warning::Could not compute title for fork PR #$PR_NUM; skipping." + exit 0 + fi + + if [ "$NEW_TITLE" = "$OLD_TITLE" ]; then + echo "PR #$PR_NUM title already correct; no change." exit 0 fi - echo "Rewriting: $OLD_TITLE -> $NEW_TITLE" gh pr edit "$PR_NUM" --title "$NEW_TITLE" + echo "PR #$PR_NUM title synced to VERSION." diff --git a/CHANGELOG.md b/CHANGELOG.md index 4870868f5..dcc271768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,185 @@ If you only want to merge, run `/land` and stop. Got ten PRs green and ready? Ru #### For contributors - `lib/merge.ts` holds the pure regime logic (detection precedence, submit planning, landing classification, handoff schema + validation); `test/gstack-merge.test.ts` (30) and `test/gstack-merge-cli.test.ts` (11) pin it. A generated-doc scrub test fails CI if `/land`'s SKILL.md ever grows deploy/canary machinery. The merge SHA → revert handoff and the never-blind-retry invariant (cli/cli#3442, cli/cli#13380) moved into `/land` with their tests. +## [1.57.3.0] - 2026-06-07 + +## **Every PR `/ship` opens gets the version stamped into its title, fork and agent PRs included.** +## **The rule rides in the always-loaded part of the skill now, and a guard keeps it there.** + +`/ship` stamps `vX.Y.Z.W` onto the title of every PR or MR it creates or updates, so +the version is the first thing you read in the PR list. That rule now lives in the +always-loaded core of the ship skill instead of an on-demand section, so the agent +applies it whether or not it opened the section that spells out the full procedure. +A CI workflow backs this up: it rewrites a title to match VERSION on every PR that +bumps the version, and it now reaches fork and agent PRs too, which a read-only token +could never touch before. Two free tests lock the behavior in so it cannot drift on +the next refactor. + +### The numbers that matter + +Reproduce with `bun test test/carve-section-ordering.test.ts test/pr-title-sync-workflow-safety.test.ts` +and `bun run eval:select`. + +| Property | Before | After | +|---|---|---| +| Where the title rule loads | on-demand section only (since v1.54.0.0) | always-loaded skeleton + on-demand detail | +| Fork / agent PR title sync | none (read-only token under `pull_request`) | covered via hardened `pull_request_target` | +| Test proving the rule stays put | none | carve-guard registry asserts it on every PR | +| CI injection guard for the title workflow | none | static tripwire fails CI on unsafe patterns | + +The title workflow now runs with a write token in the base-repo context but never +checks out or executes PR-head code, and every attacker-controlled field reaches the +script through `env:`, never inlined. A static test fails CI if either rule regresses. + +### What this means for you + +Ship a branch and the PR shows up titled `v1.57.3.0 fix: ...` without you touching it, +even when the PR came from a fork. The agent no longer needs to read the right section +at the right moment for the version to land in the title, and the next person who slims +the ship skill cannot quietly strand the rule again, because a free test on every PR +checks that it is still there. + +### Itemized changes + +#### Added +- Carve-guard coverage for the ship PR-title invariant: the registry now asserts the + `v$NEW_VERSION` rule and the title helper stay in the always-loaded skeleton, while + the full create and update procedure stays in the on-demand section. +- Static CI-safety test for the title-sync workflow that fails the build if it checks + out PR-head code or inlines an attacker-controlled PR field into a shell step. + +#### Changed +- The PR/MR title-version rule is always-loaded in `/ship` again, so the version + prefix lands on every PR the workflow creates or updates. +- The PR title-sync CI workflow now covers fork and agent PRs through a hardened + `pull_request_target` trigger (base-repo checkout only, PR fields passed via `env:`, + VERSION read as data from the PR head). + +#### Fixed +- A path token in the ship PR-body section that rendered literally instead of resolving + now uses the correct helper path, so the Linked Spec auto-detect step runs as written. + +## [1.57.2.0] - 2026-06-08 + +## **When the question picker breaks mid-skill, gstack asks in plain text instead of stalling.** +## **Every skill detects a dead AskUserQuestion and falls back to a full decision brief you answer by typing a letter.** + +AskUserQuestion is how every gstack skill asks you to decide. When the host's question +tool fails at runtime, which Conductor's MCP integration currently does intermittently, +skills used to stall or hard-block. Now each skill detects the failure, works out +whether a human is actually present, and if so re-renders the exact same decision as a +text message: a plain-English explanation of the issue, a completeness score on each +choice, and a recommendation with its reason, one paragraph per choice. You answer by +typing a single letter. Headless eval runs still block cleanly (no human to answer); +orchestrator sessions keep auto-choosing. This whole release was built and reviewed +through that fallback, because the Conductor tool was down the entire session. + +### The numbers that matter + +No production benchmark for a reliability path like this. These are the behavior and +coverage facts, verifiable with `bun test test/gstack-session-kind.test.ts +test/resolver-ask-user-format.test.ts test/auq-error-fallback-hook.test.ts`. + +| When AskUserQuestion fails | Before | After | +|---|---|---| +| Interactive session (human present) | stall / hard BLOCK | full prose decision brief, answer by letter | +| Headless eval / CI | BLOCK | BLOCK (unchanged, correct) | +| Orchestrator (OpenClaw) session | undefined | auto-choose recommended (contract kept) | +| Session kinds detected | 0 | 3 (interactive / headless / spawned) | +| New tests guarding the path | 0 | 34 | + +The text brief is not a degraded stub. It carries the same three things the picker +shows: a clear explanation of what is being decided, a `Completeness: X/10` on every +choice, and a recommendation with the reason it wins. + +### What this means for you + +If your host's question tool flakes out, a skill no longer dies on you. You get the +same decision to make, in text, and you reply with a letter. Nothing changes when the +tool works normally. If you run gstack headless, those sessions still block on a needed +question exactly as before, so eval determinism is intact. + +### Itemized changes + +#### Added +- `gstack-session-kind` classifies each session as interactive, headless, or spawned, + echoed as `SESSION_KIND` at skill start so any skill can branch on it. +- Plain-text fallback for AskUserQuestion: on a tool failure in an interactive session, + the skill renders the full decision brief (issue ELI10 + per-choice completeness + + recommendation) as markdown you answer by typing a letter, then stops and waits. +- A defensive hook that, when an AskUserQuestion call errors, reminds the agent to run + the fallback for the current session kind. + +#### Changed +- AskUserQuestion is still sent as a normal tool call; the prose path applies only when + the tool is unavailable or erroring, and never on a `[plan-tune auto-decide]` result. + +#### Fixed +- Section-loading tests use the canonical kebab test names, so the test-coverage gate + matches them. +- External-host doc-freshness checks are deterministic, no longer dependent on a prior + full regeneration. + +#### For contributors +- The eval/E2E runners set `GSTACK_HEADLESS=1` so headless runs classify correctly; + interactive-path suites opt out per-run. +- Per-skill `maxSizeRatio` override in the carve-guards registry; `document-release` + gets 1.08 headroom for the cross-cutting preamble addition while every other skill + keeps the 1.05 ceiling. + +## [1.57.0.0] - 2026-06-07 + +## **Three more heavyweight skills load lighter, and every carved skill finally has a test that proves it loads.** +## **`/cso`, `/document-release`, and `/design-consultation` shed ~49KB of always-loaded prose; CI now blocks any carve that ships without its guards.** + +gstack splits its biggest skills into a small always-loaded skeleton plus on-demand +sections that load only when a step needs them. This release carves three more, +`/document-release`, `/design-consultation`, and `/cso`, so the first time you invoke +them the agent reads far less. It also closes a gap from the earlier carves: only two +of six already-carved skills had a test proving an agent actually reads the section it +was told to read. Now all nine carved skills are guarded the same way, and CI blocks +any future carve that ships without its guards. `/cso` got extra care: its mode +dispatch and false-positive-filtering rules stay always-loaded, so a security audit +can never run with a rule stranded in an unread section. + +### The numbers that matter + +Measured with `wc -c /SKILL.md`; the skeleton+sections union is reproduced by +`bun test test/parity-suite.test.ts test/skill-size-budget.test.ts`. + +| Skill | Always-loaded before | After | Δ | +|---|---|---|---| +| /design-consultation | 80,719 B | 59,229 B | **−27%** | +| /document-release | 59,256 B | 45,797 B | **−23%** | +| /cso | 79,383 B | 65,117 B | **−18%** | +| Carved skills with a section-load guard | 2 of 6 | 9 of 9 | **full coverage** | + +Total always-loaded prose across the three skills drops about 49KB (~12K tokens) on +first invoke, with nothing lost: every line moved into an on-demand section the +skeleton points at, and the parity suite checks the union still contains it. + +### What this means for you + +Run `/cso`, `/document-release`, or `/design-consultation` and the agent does less +reading before it starts working, so the session stays leaner. The carve pattern is +now safe to extend: a free static test runs on every PR and a behavioral test runs +weekly to prove the agent reads each section, so future slimming can't quietly drop +behavior. Nothing about how you invoke these skills changed. + +### Itemized changes + +#### Added +- Canonical carved-skill guard registry (`test/helpers/carve-guards.ts`): one source of truth for which skills are carved and what each must preserve. `parity-harness.ts` and `skill-size-budget.ts` derive their carved-skill lists from it. +- Carve guard suite: data-driven static ordering test, behavioral section-loading test (periodic), a completeness meta-guard that fails CI if a carved skill lacks its guards, and negative tests proving the guards actually fire. +- `/cso`, `/document-release`, and `/design-consultation` carved into skeleton + on-demand sections. + +#### Changed +- `/cso` keeps its mode dispatch (`## Arguments`, `## Mode Resolution`), always-run phases, and false-positive-filtering exceptions always-loaded; an earliest-use invariant enforces that dispatch appears before any on-demand read. + +#### For contributors +- Redaction, taxonomy, and parity content tests now read the skeleton+sections union so relocated prose still counts toward coverage. +- Real-session section-read canary deferred to TODOS (the deterministic guards ship first). + ## [1.56.1.0] - 2026-06-03 ## **`/sync-gbrain` can no longer delete your repo. Cleanup now refuses any directory it cannot prove it created.** diff --git a/SKILL.md b/SKILL.md index 60405f27d..24c498292 100644 --- a/SKILL.md +++ b/SKILL.md @@ -45,6 +45,9 @@ echo "SKILL_PREFIX: $_SKILL_PREFIX" source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true REPO_MODE=${REPO_MODE:-unknown} echo "REPO_MODE: $REPO_MODE" +_SESSION_KIND=$(~/.claude/skills/gstack/bin/gstack-session-kind 2>/dev/null || echo "interactive") +case "$_SESSION_KIND" in spawned|headless|interactive) ;; *) _SESSION_KIND="interactive" ;; esac +echo "SESSION_KIND: $_SESSION_KIND" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" _TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) @@ -124,7 +127,7 @@ In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`co ## Skill Invocation During Plan Mode -If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, the skill is BLOCKED — stop and report `BLOCKED — AskUserQuestion unavailable` per the AskUserQuestion Format rule. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. +If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?" diff --git a/TODOS.md b/TODOS.md index 93b24c446..de8d1c133 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2283,3 +2283,54 @@ into `test/helpers/fake-gbrain.ts` when the second consumer arrives runs). **Depends on:** None. + +### P2: Real-session carve canary (E3, deferred from carve-guard plan) + +**What:** Wire a real-session section-Read-miss canary on top of the +carved skills. When a real user session drives a carved skill and the +agent does NOT Read a section the skeleton's STOP directive pointed it +at, log it (salted, content-free) to +`~/.gstack/analytics/section-reads.jsonl` and surface drift via +`bun run eval:summary`. Non-blocking alert, never a merge gate +(real-session data is non-deterministic). + +**Why:** The static (E2) + behavioral (T2) guards prove carves are +structurally sound and that a real agent Reads sections in a controlled +eval. They do NOT see production drift — a prompt-context change that +makes live agents start skipping a section. The canary is the only +mechanism that catches that, from real usage. + +**Context:** Deferred from the carve-guard-hardening plan (D5→T2, codex +outside-voice #7). `test/helpers/transcript-section-logger.ts` exists but +is built for deterministic test transcripts + ship action fingerprints, +NOT real-session drift — it needs rework before it can back this. Ship +the deterministic guards first; add this once they've proven useful. The +carved-skill set + each skill's `requiredReads` are already declared in +`test/helpers/carve-guards.ts`, so the canary reads its expectations +from there. + +**Effort:** M (human ~2d, CC ~4h). + +**Depends on:** `transcript-section-logger.ts` real-session-drift rework. + +### P2: Harden behavioral section-loading test hermeticity + +**What:** `captureSectionReads` in `test/helpers/auq-sdk-capture.ts` accepts ANY +Read whose path matches `sections/.md`. The skeleton's STOP-Read directive +points at the gstack-root install path (`scripts/resolvers/sections.ts` builds it +from `ctx.paths.skillRoot`), not the planted fixture copy. So a run can satisfy +the section-read assertion by reading the GLOBAL install's section instead of the +hermetic fixture. + +**Why:** A behavioral test that passes by reading the global install doesn't prove +THIS branch's carved section loads. If the fixture's section were broken but the +global install's weren't, the test would still pass. + +**Context:** Codex outside-voice finding on the carve-guard ship (v1.57.0.0). +Pre-existing in `auq-sdk-capture.ts` — affects `skill-e2e-ship-section-loading`, +`skill-e2e-plan-ceo-review-section-loading`, and the new +`carve-section-loading.test.ts`. Fix: match the fixture's ABSOLUTE sections path +(the `planDir` copy), not a bare `sections/.md` regex; or rewrite the STOP +path to the fixture during the run. + +**Effort:** S (human ~3h, CC ~30min). **Depends on:** None. diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index 5d5f6334c..fd6bb8a21 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -54,6 +54,9 @@ echo "SKILL_PREFIX: $_SKILL_PREFIX" source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true REPO_MODE=${REPO_MODE:-unknown} echo "REPO_MODE: $REPO_MODE" +_SESSION_KIND=$(~/.claude/skills/gstack/bin/gstack-session-kind 2>/dev/null || echo "interactive") +case "$_SESSION_KIND" in spawned|headless|interactive) ;; *) _SESSION_KIND="interactive" ;; esac +echo "SESSION_KIND: $_SESSION_KIND" _LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") echo "LAKE_INTRO: $_LAKE_SEEN" _TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) @@ -133,7 +136,7 @@ In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`co ## Skill Invocation During Plan Mode -If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, the skill is BLOCKED — stop and report `BLOCKED — AskUserQuestion unavailable` per the AskUserQuestion Format rule. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. +If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode. If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?" @@ -305,11 +308,31 @@ AI orchestrator (e.g., OpenClaw). In spawned sessions: **Rule:** if any `mcp__*__AskUserQuestion` variant is in your tool list, prefer it. Hosts may disable native AUQ via `--disallowedTools AskUserQuestion` (Conductor does, by default) and route through their MCP variant; calling native there silently fails. Same questions/options shape; same decision-brief format applies. -**If no AskUserQuestion variant appears in your tool list, this skill is BLOCKED.** Stop, report `BLOCKED — AskUserQuestion unavailable`, and wait for the user. Do not write decisions to the plan file as a substitute, do not emit them as prose and stop, and do not silently auto-decide (only `/plan-tune` AUTO_DECIDE opt-ins authorize auto-picking). +If AskUserQuestion is unavailable (no variant in your tool list) OR a call to it fails, do NOT silently auto-decide or write the decision to the plan file as a substitute. Follow the **failure fallback** below. + +### When AskUserQuestion is unavailable or a call fails + +Tell three outcomes apart: + +1. **Auto-decide denial (NOT a failure).** The result contains `[plan-tune auto-decide]