fix: integration repairs across the wave (Step 6.5 fence, printed helper paths on Windows, probe usage without a heredoc) and tests pinned to the moved title and gstack-post contracts

This commit is contained in:
garrytan committed 2026-10-07 22:01:04 +00:00
1 parent 068a3099ba
commit a740353105
16 files changed
+135 -75

No files matched your search

+13 -14
View File
@@ -556,20 +556,19 @@ _gstack_codex_log_hang() {
# --- Executed subcommands ----------------------------------------------------
_gstack_codex_usage() {
cat <<'EOF'
Usage: gstack-codex-probe <subcommand> [args]
select-model <exec|review> [--model <id>] print CODEX_SEL:, CODEX_SEL_KIND: and CODEX_SANDBOX:; exit 0 or 1
check-auth exit 0 when Codex credentials exist, 1 otherwise
show-sandbox print CODEX_SANDBOX: read-only (or danger-full-access)
check-sandbox free sandbox check; exit 0, or 3 with the reason on stderr
probe-model <exec|review> [--model <id>] paid round trip; print CODEX_PROBE_STATE:; exit 0, 1, 2 or 4
check-version warn on stderr about known-bad Codex CLI versions; exit 0
show-first-use-notice once per machine, say where reviews go; exit 0
run-with-timeout <secs> <cmd...> run cmd under a deadline; its exit code, 124 on timeout
log-event <event> [detail] record a telemetry event when telemetry is on; exit 0
log-hang <skill> <prompt-size> record a timeout learning; exit 0
help this text
EOF
printf '%s\n' \
'Usage: gstack-codex-probe <subcommand> [args]' \
' select-model <exec|review> [--model <id>] print CODEX_SEL:, CODEX_SEL_KIND: and CODEX_SANDBOX:; exit 0 or 1' \
' check-auth exit 0 when Codex credentials exist, 1 otherwise' \
' show-sandbox print CODEX_SANDBOX: read-only (or danger-full-access)' \
' check-sandbox free sandbox check; exit 0, or 3 with the reason on stderr' \
' probe-model <exec|review> [--model <id>] paid round trip; print CODEX_PROBE_STATE:; exit 0, 1, 2 or 4' \
' check-version warn on stderr about known-bad Codex CLI versions; exit 0' \
' show-first-use-notice once per machine, say where reviews go; exit 0' \
' run-with-timeout <secs> <cmd...> run cmd under a deadline; its exit code, 124 on timeout' \
' log-event <event> [detail] record a telemetry event when telemetry is on; exit 0' \
' log-hang <skill> <prompt-size> record a timeout learning; exit 0' \
' help this text'
}
# Parses "<exec|review> [--model <id>]" into _GSTACK_CODEX_ARG_KIND and
+3 -1
View File
@@ -112,7 +112,9 @@ function storedConsent(env: EarlyEnv): "yes" | "no" | undefined {
return v === "yes" || v === "no" ? v : undefined;
}
const config = (env: EarlyEnv) => path.join(env.binDir, "gstack-config");
/** A gstack helper path as printed for the user's shell: forward slashes on every platform (Git Bash on Windows). */
const helper = (env: EarlyEnv, name: string) => `${env.binDir.replace(/\\/g, "/").replace(/\/+$/, "")}/${name}`;
const config = (env: EarlyEnv) => helper(env, "gstack-config");
const SIGNAL_TEXT: Record<Exclude<Signal, "none">, string> = {
"config-folder": "a .greptile/ config folder",
"greptile.json": "greptile.json",
+22 -17
View File
@@ -18,12 +18,14 @@ Steps 7–9 run; Step 10 collects its comments. Without Greptile nothing changes
A) Push early for this repo (remembered) · B) Keep today's order for this repo (remembered).
Run `~/.claude/skills/gstack/bin/gstack-greptile-early consent yes` (A) or `consent no` (B).
B continues to Step 7.
- **`GREPTILE_EARLY: on`**, or after A — show the user the printed `Greptile: found …` line, then:
1. Push: `git push -u origin <branch-name>`. If it fails, record
"Greptile early review: not run (push failed)" and continue to Step 7; Step 17 pushes later.
2. Write a provisional `<type>: <summary>` title (Step 18 replaces it) into a private file:
- **`GREPTILE_EARLY: on`**, or after A — show the user the printed `Greptile: found …` line, then follow 1-3 below.
```bash
**1.** Push: `git push -u origin <branch-name>`. If it fails, record
"Greptile early review: not run (push failed)" and continue to Step 7; Step 17 pushes later.
**2.** Write a provisional `<type>: <summary>` title (Step 18 replaces it) into a private file:
```bash
_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"
mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }
_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; }
@@ -31,18 +33,21 @@ TITLE_FILE=$(mktemp "${_GT:?}/early-title.XXXXXX") || { echo "Not sent: mktemp f
```
Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.
3. Open the draft, substituting the printed name. Add `--ready` only when the user asked
for a non-draft PR:
```bash
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
~/.claude/skills/gstack/bin/gstack-greptile-early open --base <base> --title-file "$TITLE_FILE"
```
It opens the PR through `gstack-post` with a body saying /ship opened it early, and
posts one `@greptileai` comment when Greptile skips drafts. Save the printed
`EARLY_PR` and `EARLY_PR_OPENED_AT` in the invocation record. `EARLY_PR: none` means a
PR was already open: Step 10 triages it as before. Exit 1 or 2 means `gstack-post`
flagged the provisional title: write a plainer one and rerun. Exit 3: record the error
and continue without early review.
**3.** Open the draft, substituting the printed name. Add `--ready` only when the user asked
for a non-draft PR:
```bash
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
~/.claude/skills/gstack/bin/gstack-greptile-early open --base <base> --title-file "$TITLE_FILE"
```
It opens the PR through `gstack-post` with a body saying /ship opened it early, and
posts one `@greptileai` comment when Greptile skips drafts. Save the printed
`EARLY_PR` and `EARLY_PR_OPENED_AT` in the invocation record. `EARLY_PR: none` means a
PR was already open: Step 10 triages it as before. Exit 1 or 2 means `gstack-post`
flagged the provisional title: write a plainer one and rerun. Exit 3: record the error
and continue without early review.
**If /ship stops after the early PR opened and before Step 19 publishes**, leave the
draft open (never close or force-push it) and post one comment saying /ship stopped
+22 -17
View File
@@ -16,24 +16,29 @@ Steps 7–9 run; Step 10 collects its comments. Without Greptile nothing changes
A) Push early for this repo (remembered) · B) Keep today's order for this repo (remembered).
Run `~/.claude/skills/gstack/bin/gstack-greptile-early consent yes` (A) or `consent no` (B).
B continues to Step 7.
- **`GREPTILE_EARLY: on`**, or after A — show the user the printed `Greptile: found …` line, then:
1. Push: `git push -u origin <branch-name>`. If it fails, record
"Greptile early review: not run (push failed)" and continue to Step 7; Step 17 pushes later.
2. Write a provisional `<type>: <summary>` title (Step 18 replaces it) into a private file:
- **`GREPTILE_EARLY: on`**, or after A — show the user the printed `Greptile: found …` line, then follow 1-3 below.
{{FREE_TEXT_FILE:TITLE_FILE=early-title}}
3. Open the draft, substituting the printed name. Add `--ready` only when the user asked
for a non-draft PR:
```bash
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
~/.claude/skills/gstack/bin/gstack-greptile-early open --base <base> --title-file "$TITLE_FILE"
```
It opens the PR through `gstack-post` with a body saying /ship opened it early, and
posts one `@greptileai` comment when Greptile skips drafts. Save the printed
`EARLY_PR` and `EARLY_PR_OPENED_AT` in the invocation record. `EARLY_PR: none` means a
PR was already open: Step 10 triages it as before. Exit 1 or 2 means `gstack-post`
flagged the provisional title: write a plainer one and rerun. Exit 3: record the error
and continue without early review.
**1.** Push: `git push -u origin <branch-name>`. If it fails, record
"Greptile early review: not run (push failed)" and continue to Step 7; Step 17 pushes later.
**2.** Write a provisional `<type>: <summary>` title (Step 18 replaces it) into a private file:
{{FREE_TEXT_FILE:TITLE_FILE=early-title}}
**3.** Open the draft, substituting the printed name. Add `--ready` only when the user asked
for a non-draft PR:
```bash
TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
~/.claude/skills/gstack/bin/gstack-greptile-early open --base <base> --title-file "$TITLE_FILE"
```
It opens the PR through `gstack-post` with a body saying /ship opened it early, and
posts one `@greptileai` comment when Greptile skips drafts. Save the printed
`EARLY_PR` and `EARLY_PR_OPENED_AT` in the invocation record. `EARLY_PR: none` means a
PR was already open: Step 10 triages it as before. Exit 1 or 2 means `gstack-post`
flagged the provisional title: write a plainer one and rerun. Exit 3: record the error
and continue without early review.
**If /ship stops after the early PR opened and before Step 19 publishes**, leave the
draft open (never close or force-push it) and post one comment saying /ship stopped
+13 -2
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, jest, test } from 'bun:test';
import { afterAll, afterEach, beforeAll, describe, expect, jest, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { tmpdir } from 'node:os';
@@ -12,7 +12,18 @@ import consumption from './fixtures/autoplan-phase-consumption-491.json';
const ROOT = fs.realpathSync(path.join(import.meta.dir, '..'));
// Every guarded decision appends to the guard log; keep it out of the real state root.
process.env.GSTACK_HOME = fs.mkdtempSync(path.join(tmpdir(), 'autoplan-guard-log-'));
let previousGstackHome: string | undefined;
let guardLogHome = '';
beforeAll(() => {
previousGstackHome = process.env.GSTACK_HOME;
guardLogHome = fs.mkdtempSync(path.join(tmpdir(), 'autoplan-guard-log-'));
process.env.GSTACK_HOME = guardLogHome;
});
afterAll(() => {
if (previousGstackHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = previousGstackHome;
fs.rmSync(guardLogHome, { recursive: true, force: true });
});
const dirs: string[] = [];
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
const phaseNumber = { ceo: 1, design: 2, dx: 2.5, eng: 3 };
+13 -2
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { tmpdir } from 'node:os';
@@ -8,7 +8,18 @@ import { runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts';
const ROOT = path.join(import.meta.dir, '..');
// Every guarded decision appends to the guard log; keep it out of the real state root.
process.env.GSTACK_HOME = fs.mkdtempSync(path.join(tmpdir(), 'autoplan-guard-log-'));
let previousGstackHome: string | undefined;
let guardLogHome = '';
beforeAll(() => {
previousGstackHome = process.env.GSTACK_HOME;
guardLogHome = fs.mkdtempSync(path.join(tmpdir(), 'autoplan-guard-log-'));
process.env.GSTACK_HOME = guardLogHome;
});
afterAll(() => {
if (previousGstackHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = previousGstackHome;
fs.rmSync(guardLogHome, { recursive: true, force: true });
});
const SHIM = path.join(ROOT, 'autoplan/bin/phase-publication-hook');
const dirs: string[] = [];
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
+3 -1
View File
@@ -10,6 +10,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { guardFixture, section, denial, codeOf, type GuardFixture } from './helpers/autoplan-guard-fixture';
import { AGENT_KEYS, CHECKED_CLAUDE_CODE, nativeToolInput, newerThanChecked } from '../autoplan/bin/phase-publication-hook.ts';
import { nativePathSpelling } from '../lib/claude-public-transcript';
import captured from './fixtures/claude-agent-payload-2.1.292.json';
const fixtures: GuardFixture[] = [];
@@ -62,7 +63,8 @@ describe('Cause A: the Agent schema strips run_in_background', () => {
for (const payload of captured.agentPayloads) for (const key of Object.keys(payload.tool_input)) expect(AGENT_KEYS).toContain(key);
expect(CHECKED_CLAUDE_CODE).toBe(captured.claudeCodeVersion);
expect(nativeToolInput({ prompt: 'p', run_in_background: 'false' }, '/', 'Agent')).toEqual({ prompt: 'p' });
expect(nativeToolInput({ file_path: 'x', run_in_background: true }, '/', 'Read')).toEqual({ file_path: '/x', run_in_background: true });
expect(nativeToolInput({ file_path: 'x', run_in_background: true }, '/', 'Read'))
.toEqual({ file_path: nativePathSpelling(path.resolve('/', 'x')), run_in_background: true });
});
test('UC1: a pure strip by a newer, unchecked Claude Code is unverified; the same strip on a checked version is denied', async () => {
@@ -1,6 +1,7 @@
/** CEO-13: the canary's comparison passes the documented strip and fails an injected one (free). */
import { describe, expect, test } from 'bun:test';
import { schemaDrift, type HookPayload } from './helpers/schema-canary';
import { SCHEMA_STRIPPED, nativeToolInput } from '../autoplan/bin/phase-publication-hook.ts';
import captured from './fixtures/claude-agent-payload-2.1.292.json';
const cwd = '/repo';
@@ -10,6 +11,13 @@ const journal = (agentInput: Record<string, unknown>) =>
new Map<string, Record<string, unknown>>([['toolu_read', { file_path: 'note.txt' }], ['toolu_agent', agentInput]]);
describe('autoplan-schema-canary comparison', () => {
test('the guard documents exactly one strip, and its comparison drops only that key from the captured inputs', () => {
expect(SCHEMA_STRIPPED).toEqual({ Agent: ['run_in_background'] });
const { payloadInput, journalInput } = captured.forcedRunInBackground;
expect(nativeToolInput(journalInput, cwd, 'Agent')).toEqual(nativeToolInput(payloadInput, cwd, 'Agent'));
expect(nativeToolInput(journalInput, cwd, 'Read')).not.toEqual(nativeToolInput(payloadInput, cwd, 'Read'));
});
test('2.1.292 as captured: run_in_background stripped from the payload is the documented strip', () => {
const { payloadInput, journalInput } = captured.forcedRunInBackground;
expect(schemaDrift([read, agent(payloadInput)], journal(journalInput))).toEqual([]);
+3
View File
@@ -126,6 +126,9 @@
"sections/changelog.md": [
"## Step 13: CHANGELOG (auto-generate)"
],
"sections/greptile-early.md": [
"## Step 6.5: Early Greptile PR (after the free tests pass)"
],
"sections/greptile.md": [
"## Step 10: Address Greptile review comments (if PR exists)"
],
+8 -6
View File
@@ -131,21 +131,23 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// contain "dispatch the" — so update anchors in lockstep with any
// touchpoint rewording.
mustStayInSkeleton: [
'v$NEW_VERSION',
'gstack-pr-title-rewrite',
'The pr-body section prepares the title from that result',
'## Step 14.5: Documentation audit (every ship)',
'No documentation writer runs after push',
'dispatches the /document-release subagent',
],
// ...while the full create/update procedure stays carved into pr-body.md
// (out of the skeleton, present in the union). Asserts BOTH PR paths
// survive: the create path and the idempotent update path. The Step 18
// survive: the create path and the idempotent update path, both posted
// through gstack-post, and the version-prefixed title preparation that
// moved into the section with them (Oct 7 wave B4). The Step 18
// dispatch imperative stays carved too — pasting that literal into the
// skeleton (correctly) fails this guard; the skeleton speaks of "the
// /document-release subagent", never the carved imperative.
mustMoveToSection: [
'gh pr create --base',
'gh pr edit --title',
'gstack-post pr-create --base',
'gstack-post pr-title',
'gstack-pr-title-rewrite.sh <new-version> --stdin',
'Dispatch /document-release as a subagent',
],
// ship is operational (multi-STOP, not a plan review); no single post-STOP gate.
@@ -596,7 +598,7 @@ do not launch the downstream skill or open a browser.`,
mustMoveToSection: [
'<<<USER_SPEC>>>',
'SEMANTIC_REVIEW: clean',
'gh issue create --title',
'gstack-post issue-create',
'PIN_SHA=$(git rev-parse HEAD)',
],
gateAfterStop: undefined,
@@ -30,6 +30,9 @@ const LATER_PREAMBLE_EDITS: Array<[string, string]> = [
const LATER_WAVE_EDITS: Array<[string, string]> = [
['tmpfile=$(mktemp "${TMPDIR:-/tmp}/bun-install.XXXXXX")', 'tmpfile=$(mktemp)'],
['BUN_VERSION="1.4.2"', 'BUN_VERSION="1.3.10"'],
// Oct 7 wave: the artifacts-sync preamble block surfaces attention lines (one line longer).
["Skill-start already ran artifacts sync. GBrain hint text (if any) says\nwhen to prefer `gbrain` over Grep. `ARTIFACTS_SYNC:` reports sync health\n(`off`, `mode=... | queue=N`, `remote-mode`, or a `gstack-brain-restore`\nhint). On an `attention:` line, tell the user in one sentence what\nit says and the command it names, then continue.\n\nThe one-time privacy stop-gate arrives as a `GSTACK_INSTRUCTION` block\nfrom skill-start when consent is pending; fire it via AskUserQuestion\nexactly as instructed.",
"The skill-start output above already ran artifacts sync. Act on its lines:\nGBrain hint text (if present) tells you when to prefer `gbrain` over Grep;\n`ARTIFACTS_SYNC:` reports sync health (`off`, `mode=... | queue=N`,\n`remote-mode`, or a restore hint naming `gstack-brain-restore`).\n\nThe one-time privacy stop-gate (artifacts-sync consent) arrives as a\n`GSTACK_INSTRUCTION` block from skill-start when consent is actually pending\n\u2014 fire it via AskUserQuestion exactly as the block instructs."],
];
export function approvedCookieWorkflowSource(source: string): string {
+2
View File
@@ -11,6 +11,8 @@ const ptyIds = [
'office-hours-section-loading', 'office-hours-design-draft', 'plan-ceo-section-loading', 'ship-section-loading',
'plan-ceo-finding-floor', 'plan-design-finding-floor', 'plan-devex-finding-floor',
'plan-eng-multi-finding-batching', 'plan-ceo-split-overflow', 'plan-eng-review-artifact', 'plan-eng-review-artifact-full',
// The /autoplan guard's live cases drive the same PTY runner (Oct 7 wave).
'autoplan-guard-pty', 'autoplan-long-session',
].sort();
test('PTY supervision controls select every current runner consumer', () => {
+2 -1
View File
@@ -286,8 +286,9 @@ describe("each input fails on its own (CEO-23, DX-9)", () => {
expect(found[0]).toContain("CODEOWNERS:2");
});
// stubGitLog puts a POSIX shell `git` wrapper first on PATH; Windows CreateProcess cannot exec it.
for (const behavior of ["fail", "hang"] as const) {
test(`a history read that ${behavior === "fail" ? "errors" : "times out"} still honors the pusher's own address and says suppression was limited`, () => {
test.skipIf(process.platform === "win32")(`a history read that ${behavior === "fail" ? "errors" : "times out"} still honors the pusher's own address and says suppression was limited`, () => {
const fx = fixture({ selfEmail: at("me") });
commitFile(fx, fx.repo, "lib.txt", "code\n", at("colleague"));
expect(push(fx, ["origin", "main"]).code).toBe(0);
+9 -8
View File
@@ -123,16 +123,17 @@ describe('pre-publication documentation lifecycle', () => {
test('PR creation and reruns keep current and blocked audits visible', () => {
const body = read('ship/sections/pr-body.md.tmpl');
expect(body).toContain("Use Step 18's `NEW_TITLE`");
expect(body).toContain('`NEW_TITLE` unchanged; its version prefix is already present');
expect(body).toContain('printf \'%s\' "$NEW_TITLE" |');
expect(body).toContain('gh pr create --base <base> --title "$NEW_TITLE"');
expect(body).toContain('gh pr edit --title "$NEW_TITLE"');
expect(body).toContain('glab mr create -b <base> -t "$NEW_TITLE"');
// Oct 7 wave B4: the title travels in Step 18's agent-written file and both
// PR paths publish through gstack-post, which scans the exact bytes it sends.
expect(body).toContain('`grep -Eq \'^v<new-version>( |$)\' "$TITLE_FILE"` and never publish an unprefixed title');
expect(body).toContain('gstack-pr-title-rewrite.sh <new-version> --stdin > "$TITLE_FILE"');
expect(body).toContain('gstack-post pr-create --base <base> --title-file "$TITLE_FILE"');
expect(body).toContain('gstack-post pr-title <pr-number> --title-file "$TITLE_FILE"');
expect(body).toContain('`gstack-post` detects GitHub or GitLab from the remote');
expect(body).not.toContain('Dispatch /document-release');
expectMentions(body, [['never', 'section', 'another']], 'body');
expect(body).toContain('gh pr edit --body-file "$PR_BODY_FILE"');
expect(body).toContain('gstack-redact --from-file "$PR_BODY_FILE"');
expect(body).toContain('gstack-post pr-body <pr-number> --body-file "${PR_BODY_FILE:?restore the composed body path}"');
expect(body).toContain('gstack-redact --from-file "$PR_BODY_FILE" --auto-redact');
expect(read('ship/SKILL.md.tmpl')).toContain('existing PRs and docs-only changes');
expect(read('ship/sections/apple-release.md.tmpl')).toContain('read-only');
expect(read('ship/sections/apple-release.md.tmpl')).toContain('ship/sections/documentation.md');
+6 -2
View File
@@ -75,8 +75,12 @@ function composed(root: string, opts: { title?: string; top?: string } = {}) {
return { ...repo, bodyFile, publish: (block: string) => repo.run(fill(block)) };
}
// The posting cases drive a POSIX stub gh that gstack-post spawns; Windows CreateProcess cannot
// exec its shebang. gstack-post's argument-array and token logic run in-process on Windows in
// test/gstack-post.test.ts.
const postsThroughStubGh = (failure: string) => process.platform === 'win32' && failure === 'none';
for (const failure of ['none', 'body', 'title'] as const) {
test(`publication posts only scanned bytes through gstack-post: ${failure}`, () => {
test.skipIf(postsThroughStubGh(failure))(`publication posts only scanned bytes through gstack-post: ${failure}`, () => {
const root = mkdtempSync(join(tmpdir(), 'ship-post-'));
try {
const secret = 'AKIA' + '1234567890ABCDEF';
@@ -100,7 +104,7 @@ for (const failure of ['none', 'body', 'title'] as const) {
});
}
test('MEDIUM findings publish nothing until the block reruns with the printed token', () => {
test.skipIf(process.platform === 'win32')('MEDIUM findings publish nothing until the block reruns with the printed token', () => {
const root = mkdtempSync(join(tmpdir(), 'ship-post-medium-'));
try {
const c = composed(root, { top: '## Summary\nThanks jane.roe@acme-corp.io\n\n## Documentation' });
+5 -4
View File
@@ -107,11 +107,12 @@ describe('workflow judge excerpts', () => {
const text = readWorkflowExcerpt('ship/SKILL.md', '# Ship:', '## Important Rules');
const publish = text.slice(text.indexOf('## Step 19:'), text.indexOf('## Step 20:'));
const compose = publish.indexOf('PR_BODY_FILE=$(mktemp');
const scan = publish.indexOf('gstack-redact --from-file "$PR_BODY_FILE"');
const edit = publish.indexOf('gh pr edit --body-file');
// gstack-post owns the scan of the exact bytes it sends (Oct 7 wave B4).
const scan = publish.indexOf('`gstack-post` scans the exact title');
const edit = publish.indexOf('gstack-post pr-body <pr-number> --body-file');
expect(compose).toBeGreaterThan(0);
expect(scan).toBeGreaterThan(compose);
expect(edit).toBeGreaterThan(scan);
expect(scan).toBeGreaterThan(0);
expect(edit).toBeGreaterThan(compose);
expect(publish.indexOf('Print the existing URL')).toBeGreaterThan(edit);
expect(text).not.toContain('Phase 8e.5');
expectMentions(text, [['never', 'create', 'commit']], 'text');