Merge origin/main (v1.65.0.0 fork port wave 2) into test-evals-ci-speedup

Second overlapping-wave merge; resolutions compose intent:

- TEST_ROOTS: ours is the superset (main also wired ios-qa/daemon/test;
  ours additionally has ios-qa/scripts + browser-skills). package.json
  'test' keeps routing through the canonical strict runner.
- gbrainAvailable: main fixed the same load-flake with a strictly better
  mechanism (memoized stat-based PATH scan, no subprocess at all) —
  theirs supersedes this branch's memoized-exec probe. Main also made
  the query timeout env-overridable (GSTACK_BRAIN_TIMEOUT_MS).
- Model defaults: adopted main's lib/eval-model.ts abstraction (one
  resolution point, env-overridable per kind) and applied decision D1a
  inside it: capture defaults to Sonnet (Opus opt-in via explicit arg or
  GSTACK_EVAL_MODEL_CAPTURE); test pins updated to follow.
- Parent watchdog: main's rewrite (named parameterized tick, driven
  deterministically by its test via __testInternals__, plus handoff
  suppression semantics from session persistence) supersedes this
  branch's env-tunable interval; adopted their server + test wholesale.
- windows-free-tests: ours (curated bun run test:windows) — main's
  hand-list grew by one more file, which the curated runner subsumes
  automatically; that drift is the reason for D11.
- context-skills 0-for-26 fix: both waves made the IDENTICAL fix; kept
  this branch's comment (carries the receipts).
- .gitignore: main's superset (also ignores Package.resolved — their
  never-commit call; untracked the copy this branch had committed).

Verified: 239-test merge battery green, watchdog 8/8, eval-model 5/5,
actionlint clean, eval:select works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-15 11:56:34 -07:00
co-authored by Claude Fable 5
200 changed files with 11004 additions and 1207 deletions
-6
View File
@@ -109,12 +109,6 @@ describe('Audit compliance', () => {
});
// Round 2 Fix 4: Chrome CDP binds to localhost only
test('chrome-cdp binds to localhost only', () => {
const cdp = readFileSync(join(ROOT, 'bin/chrome-cdp'), 'utf-8');
expect(cdp).toContain('--remote-debugging-address=127.0.0.1');
expect(cdp).toContain('--remote-allow-origins=');
});
// Fix 2+6: All generated SKILL.md files with telemetry are conditional
test('all generated SKILL.md files with telemetry calls use conditional pattern', () => {
const skills = getAllSkillMds();
+151
View File
@@ -0,0 +1,151 @@
/**
* Free unit tests for the ClaudeAdapter auth sniff in
* test/helpers/providers/claude.ts — specifically the macOS Keychain branch
* (#1890): the default subscription install stores OAuth under the
* generic-password service "Claude Code-credentials" and never writes
* ~/.claude/.credentials.json, so availability must consult
* `security find-generic-password -s "Claude Code-credentials"` when neither
* the creds file nor ANTHROPIC_API_KEY exists.
*
* The `security` spawn is darwin-gated in the adapter (the probe only runs
* when process.platform === 'darwin'), so the keychain-branch tests skip
* elsewhere; the creds-file / env-key / no-auth verdicts run on every
* platform.
*
* Harness note: available() spawns `security` without an explicit `env:`, and
* Bun resolves such spawns against the PROCESS STARTUP env — mutating
* process.env.PATH in-test cannot shadow the real /usr/bin/security (verified;
* same Bun property lib/gbrain-exec.ts documents for DATABASE_URL). So each
* case runs available() in a child `bun -e` whose env (and PATH shim) the test
* fully controls; the fake `security` logs its argv, so the operator's real
* Keychain is never consulted and a logged-in machine can't false-pass the
* "no auth" cases. cwd is the fake home so Bun doesn't autoload the repo .env
* (which defines ANTHROPIC_API_KEY).
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { spawnSync } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
const ADAPTER = path.join(import.meta.dir, "helpers", "providers", "claude.ts");
describe("ClaudeAdapter.available() — auth sniff incl. macOS Keychain branch (#1890)", () => {
let fakeHome: string;
let shimDir: string;
let securityLog: string;
beforeEach(() => {
fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "claude-auth-home-"));
shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-auth-bin-"));
securityLog = path.join(shimDir, "security-argv.log");
});
afterEach(() => {
fs.rmSync(fakeHome, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
});
/** Fake `security` that logs its argv and exits with `exitCode`. */
function writeFakeSecurity(exitCode: number): void {
fs.writeFileSync(
path.join(shimDir, "security"),
`#!/bin/sh
printf '%s\\n' "$*" >> "${securityLog}"
exit ${exitCode}
`,
{ mode: 0o755 },
);
}
/** Run `new ClaudeAdapter().available()` in a child bun with a controlled env. */
function runAvailable(opts: { anthropicKey?: string; bareShimPath?: boolean } = {}): { ok: boolean; reason?: string } {
const env: Record<string, string | undefined> = {
...process.env,
// Shim dir first so a fake `security` shadows /usr/bin/security; the
// bare variant drops the inherited PATH entirely (no `claude` findable).
PATH: opts.bareShimPath ? shimDir : `${shimDir}${path.delimiter}${process.env.PATH ?? ""}`,
// os.homedir() honors $HOME (POSIX) / %USERPROFILE% (Windows), so the
// adapter's ~/.claude/.credentials.json check reads the fake home.
HOME: fakeHome,
USERPROFILE: fakeHome,
// Absolute-path override satisfies resolveClaudeCommand() without a real
// claude install — available() never spawns it, only resolves it. The
// bare-PATH case clears it to exercise the not-found branch.
GSTACK_CLAUDE_BIN: opts.bareShimPath ? undefined : path.join(shimDir, "claude"),
CLAUDE_BIN: undefined,
GSTACK_CLAUDE_BIN_ARGS: undefined,
CLAUDE_BIN_ARGS: undefined,
ANTHROPIC_API_KEY: opts.anthropicKey,
};
for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k];
const driver = `const { ClaudeAdapter } = await import(${JSON.stringify(ADAPTER)});
const check = await new ClaudeAdapter().available();
console.log(JSON.stringify(check));`;
// process.execPath (absolute bun binary): the bare-PATH case strips the
// inherited PATH, so a name-based "bun" lookup would ENOENT.
const res = spawnSync(process.execPath, ["-e", driver], {
encoding: "utf-8",
timeout: 30_000,
cwd: fakeHome, // no .env here — the repo root's would inject ANTHROPIC_API_KEY
env: env as Record<string, string>,
});
if (res.status !== 0) throw new Error(`driver failed (${res.status}): ${res.stderr}`);
return JSON.parse(res.stdout.trim()) as { ok: boolean; reason?: string };
}
test("creds file present → available, and the Keychain is never consulted", () => {
fs.mkdirSync(path.join(fakeHome, ".claude"), { recursive: true });
fs.writeFileSync(path.join(fakeHome, ".claude", ".credentials.json"), "{}");
// A fake security that would report NOT FOUND — if the probe ran anyway,
// ok would still be true, so the no-spawn pin is the argv log staying empty.
writeFakeSecurity(1);
const check = runAvailable();
expect(check.ok).toBe(true);
expect(fs.existsSync(securityLog)).toBe(false);
});
test("ANTHROPIC_API_KEY set → available without creds file or Keychain probe", () => {
writeFakeSecurity(1);
const check = runAvailable({ anthropicKey: "sk-ant-test-not-a-real-key" });
expect(check.ok).toBe(true);
expect(fs.existsSync(securityLog)).toBe(false);
});
test("darwin: Keychain hit (security exit 0) → available, probed with the exact service name", () => {
if (process.platform !== "darwin") return; // keychain branch is darwin-gated in the adapter
writeFakeSecurity(0);
const check = runAvailable();
expect(check.ok).toBe(true);
// Exactly one metadata-only probe, against the service subscription installs use.
const argv = fs.readFileSync(securityLog, "utf-8").trim().split("\n");
expect(argv).toEqual(["find-generic-password -s Claude Code-credentials"]);
expect(argv[0]).not.toContain("-w"); // never reads the secret itself
});
test("darwin: Keychain miss (security exit 1) → not available with the no-auth reason", () => {
if (process.platform !== "darwin") return;
writeFakeSecurity(1);
const check = runAvailable();
expect(check.ok).toBe(false);
expect(check.reason).toContain("No Claude auth found");
// The probe DID run (this is the miss path, not a skipped probe).
expect(fs.readFileSync(securityLog, "utf-8")).toContain("find-generic-password");
});
test("non-darwin: no creds file + no env key → not available (no Keychain to consult)", () => {
if (process.platform === "darwin") return; // darwin covered by the shimmed miss case above
const check = runAvailable();
expect(check.ok).toBe(false);
expect(check.reason).toContain("No Claude auth found");
});
test("claude CLI unresolvable → not-found reason, before any auth sniff", () => {
writeFakeSecurity(0); // even a keychain HIT can't rescue a missing binary
const check = runAvailable({ bareShimPath: true });
expect(check.ok).toBe(false);
expect(check.reason).toContain("claude CLI not found on PATH");
expect(fs.existsSync(securityLog)).toBe(false); // returned before the auth sniff
});
});
File diff suppressed because it is too large Load Diff
+34
View File
@@ -152,6 +152,40 @@ describe("upstream fix a: root-as-container + walkMd exclusions", () => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("totalMd: a container skill excludes nested child skills' bytes; the grand total sums per-skill with no overlap", () => {
// Regression pin for the v1.63 double-count: totalMd on a skill dir that
// CONTAINS other skill dirs (the gstack root wraps the whole tree) used to
// swallow the children's .md bytes too, so the TOTAL line billed every
// nested skill twice. A revert of the topSeg/SKILL.md skip in totalMd
// (lib/context-bill.ts) must fail here.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-nested-total-"));
fs.writeFileSync(path.join(tmp, "SKILL.md"), "---\nname: parent\ndescription: p\n---\n# Parent\n");
fs.writeFileSync(path.join(tmp, "NOTES.md"), "n".repeat(1_000));
// A non-skill subdir (no SKILL.md) still belongs to the parent's total.
fs.mkdirSync(path.join(tmp, "references"));
fs.writeFileSync(path.join(tmp, "references", "GUIDE.md"), "g".repeat(2_000));
// Nested child skill with a LARGE .md — the bytes a revert double-counts.
fs.mkdirSync(path.join(tmp, "child"));
fs.writeFileSync(path.join(tmp, "child", "SKILL.md"), "---\nname: child\ndescription: c\n---\n# Child\n");
fs.writeFileSync(path.join(tmp, "child", "BIG.md"), "x".repeat(50_000));
const bill = buildBill(tmp);
const parent = bill.skills.find((s) => s.name !== "child")!;
const child = bill.skills.find((s) => s.name === "child")!;
expect(bill.skills).toHaveLength(2);
const parentOwn =
fileBytes(tmp, "SKILL.md") + fileBytes(tmp, "NOTES.md") + fileBytes(tmp, "references", "GUIDE.md");
const childOwn = fileBytes(tmp, "child", "SKILL.md") + fileBytes(tmp, "child", "BIG.md");
// Parent's total is its OWN files only — the child's 50KB is excluded.
expect(parent.totalMdBytes).toBe(parentOwn);
expect(child.totalMdBytes).toBe(childOwn);
// Grand total = sum of per-skill figures, every byte billed exactly once.
expect(bill.totals.totalMdBytes).toBe(parentOwn + childOwn);
expect(bill.totals.totalMdBytes).toBe(parent.totalMdBytes + child.totalMdBytes);
fs.rmSync(tmp, { recursive: true, force: true });
});
it("walkMd skips node_modules and dot-directories", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-walk-"));
fs.writeFileSync(path.join(tmp, "real.md"), "x");
+7
View File
@@ -67,6 +67,13 @@ const MODULE_SINKS = [
'bin/gstack-gbrain-sync.ts',
'bin/gstack-memory-ingest.ts',
'browse/src/server.ts',
// Code-intelligence adapters (fork port wave 2): the gbrain adapter shells
// repo content to the user's gbrain DB and the Sourcebot adapter POSTs
// queries to a self-hosted HTTP endpoint — both sensitive-class
// (repo-content) sinks, fail-closed. Registered so a refactor that drops
// their writeReceipt calls fails CI, not just the tree-sweep scanner.
'lib/code-intelligence/gbrain-adapter.ts',
'lib/code-intelligence/sourcebot-adapter.ts',
// Unconditional: context-bill ships in the same tree as this tripwire. A
// missing file must fail loudly (a rename/move that drops its receipt wiring
// is exactly what this pins), not silently soften the assertion.
+25
View File
@@ -0,0 +1,25 @@
/** OV11: the host-neutral eval-model resolver's contract. */
import { describe, test, expect } from "bun:test";
import { resolveEvalModel } from "../lib/eval-model";
describe("resolveEvalModel", () => {
test("explicit argument wins over everything", () => {
expect(resolveEvalModel("capture", "my-model", { GSTACK_EVAL_MODEL: "x" } as never)).toBe("my-model");
});
test("per-kind env beats the global env", () => {
expect(resolveEvalModel("warmup", null, { GSTACK_EVAL_MODEL_WARMUP: "w", GSTACK_EVAL_MODEL: "g" } as never)).toBe("w");
});
test("global env beats the default", () => {
expect(resolveEvalModel("distill", null, { GSTACK_EVAL_MODEL: "g" } as never)).toBe("g");
});
test("defaults per kind", () => {
// capture defaults to Sonnet per D1a (2026-08 review): Opus is opt-in via
// explicit arg or GSTACK_EVAL_MODEL_CAPTURE.
expect(resolveEvalModel("capture", null, {} as never)).toBe("claude-sonnet-4-6");
expect(resolveEvalModel("warmup", null, {} as never)).toBe("claude-haiku-4-5");
expect(resolveEvalModel("distill", null, {} as never)).toBe("claude-haiku-4-5-20251001");
});
test("unknown kind throws instead of silently defaulting", () => {
expect(() => resolveEvalModel("banana" as never, null, {} as never)).toThrow();
});
});
+31
View File
@@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
@@ -808,6 +812,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
## Third-Party Web Actions
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
## Step 0: Detect platform and base branch
First, detect the git hosting platform from the remote URL:
@@ -895,6 +913,7 @@ sections. Read a section in full before doing its step; do not work from memory.
| When | Read this section |
|------|-------------------|
| the ship target is an Apple platform app (.xcodeproj, .xcworkspace, or an app-product Swift package) — read BEFORE Step 1's branch gate and any preflight; store distribution never routes through the branch/PR ceremony | `sections/apple-release.md` |
| running the test suites and (if prompt files changed) the eval suites (Steps 4-6) | `sections/tests.md` |
| auditing test coverage of the diff (Step 7) | `sections/test-coverage.md` |
| auditing plan completion, verification, and scope drift (Step 8) | `sections/plan-completion.md` |
@@ -906,6 +925,18 @@ sections. Read a section in full before doing its step; do not work from memory.
---
## Step 0.9: Apple target detection
Shipping to the App Store is not landing a PR. If the repository contains an
`.xcodeproj`, `.xcworkspace`, or a Swift package with an app product AND the
user's ask is store distribution (App Store, TestFlight, "release my app"),
**STOP and Read `~/.claude/skills/gstack/ship/sections/apple-release.md` FIRST**
— before the branch gate and any preflight below. Store distribution proceeds
from whatever branch the user is on (a clean tree on the base branch is the
solo developer's normal case, not an error) and follows the adapter end to
end. The branch gate and repository-landing pipeline below apply ONLY to
repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
1. Check the current branch. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
+98 -23
View File
@@ -664,6 +664,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
@@ -794,6 +798,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
## Third-Party Web Actions
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
## Step 0: Detect platform and base branch
First, detect the git hosting platform from the remote URL:
@@ -878,6 +896,18 @@ Never skip a verification step because a prior `/ship` run already performed it.
---
## Step 0.9: Apple target detection
Shipping to the App Store is not landing a PR. If the repository contains an
`.xcodeproj`, `.xcworkspace`, or a Swift package with an app product AND the
user's ask is store distribution (App Store, TestFlight, "release my app"),
**STOP and Read `$GSTACK_ROOT/ship/sections/apple-release.md` FIRST**
— before the branch gate and any preflight below. Store distribution proceeds
from whatever branch the user is on (a clean tree on the base branch is the
solo developer's normal case, not an error) and follows the adapter end to
end. The branch gate and repository-landing pipeline below apply ONLY to
repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
1. Check the current branch. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
@@ -999,41 +1029,70 @@ git fetch origin <base> && git merge origin/<base> --no-edit
## Test Framework Bootstrap
**Detect existing test framework and project runtime:**
**Read the project's AGENTS.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5.
**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse.
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
# Detect project runtime
[ -f Gemfile ] && echo "RUNTIME:ruby"
# Definitive ecosystem markers (presence = ecosystem, NOT a command to run)
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py"
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
[ -f package.json ] && echo "RUNTIME:node"
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
[ -f go.mod ] && echo "RUNTIME:go"
[ -f Cargo.toml ] && echo "RUNTIME:rust"
[ -f composer.json ] && echo "RUNTIME:php"
[ -f mix.exs ] && echo "RUNTIME:elixir"
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
# Detect sub-frameworks
[ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails"
[ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs"
# Check for existing test infrastructure
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
# Existing test path — config files, declared scripts, AND test FILES.
# A project with real tests and no config file is the common miss.
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest"
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
# Rust keeps unit tests inside src/, so file names alone miss them
[ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source"
# Check opt-out marker
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"
```
**If test framework detected** (config files or test directories found):
Print "Test framework detected: {name} ({N} existing tests). Skipping bootstrap."
Map the markers to the command you will OFFER — never to one you run on a guess:
| Marker | Ecosystem | Candidate command to offer |
|--------|-----------|----------------------------|
| `manage.py` | Django | `python manage.py test` (or `pytest` when pytest-django is in the deps) |
| `pytest.ini` / `tox.ini` / pytest in `pyproject.toml` / `test_*.py` | Python | `pytest` |
| `go.mod` (+ any `*_test.go`) | Go | `go test ./...` |
| `Cargo.toml` | Rust | `cargo test` |
| `pom.xml` | JVM (Maven) | `mvn test` |
| `build.gradle` / `build.gradle.kts` | JVM (Gradle) | `./gradlew test` |
| `Gemfile` / `Rakefile` / `.rspec` | Ruby | `bundle exec rspec`, `bin/rails test`, or `rake test` |
| `mix.exs` | Elixir | `mix test` |
| `composer.json` | PHP | `composer test` or `./vendor/bin/phpunit` |
| `package.json` with a `test` script | Node | that script, run with the package manager the lockfile names |
| `Makefile` with a `test:` target | any | `make test` |
**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — AGENTS.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to AGENTS.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one.
Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns).
Store conventions as prose context for use in Phase 8e.5 or Step 7. **Skip the rest of bootstrap.**
Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `<app>/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. A green `python manage.py test` with no `pytest.ini` is a tested project, not a bootstrap candidate.
**If BOOTSTRAP_DECLINED** appears: Print "Test bootstrap previously declined — skipping." **Skip the rest of bootstrap.**
**If NO runtime detected** (no config files found): Use AskUserQuestion:
**If NO ecosystem marker matched:** Use AskUserQuestion:
"I couldn't detect your project's language. What runtime are you using?"
Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests.
If the runtime you need isn't listed, offer "Other" and take the runtime plus the test command as free text.
If user picks H → write `.gstack/no-test-bootstrap` and continue without tests.
**If runtime detected but no test framework — bootstrap:**
**If an ecosystem matched but there is no existing-test evidence at all — bootstrap:**
### B2. Research best practices
@@ -1049,7 +1108,9 @@ If WebSearch is unavailable, use this built-in knowledge table:
| Node.js | vitest + @testing-library | jest + @testing-library |
| Next.js | vitest + @testing-library/react + playwright | jest + cypress |
| Python | pytest + pytest-cov | unittest |
| Django | pytest + pytest-django | Django's built-in `manage.py test` (unittest) |
| Go | stdlib testing + testify | stdlib only |
| JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only |
| Rust | cargo test (built-in) + mockall | — |
| PHP | phpunit + mockery | pest |
| Elixir | ExUnit (built-in) + ex_machina | — |
@@ -1378,15 +1439,20 @@ Before analyzing coverage, detect the project's test framework:
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
# Detect project runtime
[ -f Gemfile ] && echo "RUNTIME:ruby"
# Detect project runtime (markers are evidence, not commands to run blind)
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django"
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
[ -f package.json ] && echo "RUNTIME:node"
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
[ -f go.mod ] && echo "RUNTIME:go"
[ -f Cargo.toml ] && echo "RUNTIME:rust"
# Check for existing test infrastructure
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini phpunit.xml 2>/dev/null
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
# Check for existing test infrastructure — config files, scripts, AND test files
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml 2>/dev/null
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
```
3. **If no framework detected:** falls through to the Test Framework Bootstrap step (Step 4) which handles full setup.
@@ -1818,16 +1884,23 @@ Using the plan file already discovered in Step 8, look for a verification sectio
### 2. Check for running dev server
Before invoking browse-based verification, check if a dev server is reachable:
Before invoking browse-based verification, find the dev-server URL the way the
project declares it — never trust a hardcoded port list alone:
1. **AGENTS.md first:** look for a documented dev URL or dev command (a
`## Development`/`## Testing` section naming a port or URL). Use it.
2. **The plan file:** if the plan's verification section names a URL, use it.
3. **Fallback probe** (common ports, only when 1-2 found nothing):
```bash
curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 2>/dev/null || \
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080 2>/dev/null || \
curl -s -o /dev/null -w '%{http_code}' http://localhost:5173 2>/dev/null || \
curl -s -o /dev/null -w '%{http_code}' http://localhost:4000 2>/dev/null || echo "NO_SERVER"
for _p in 3000 8080 5173 4000 4321 8000; do
_code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$_p" 2>/dev/null)
[ -n "$_code" ] && [ "$_code" != "000" ] && { echo "DEV_SERVER: http://localhost:$_p ($_code)"; break; }
done
[ -z "${_code:-}" ] || [ "${_code:-000}" = "000" ] && echo "NO_SERVER"
```
**If NO_SERVER:** Skip with "No dev server detected — skipping plan verification. Run /qa separately after deploying."
**If NO_SERVER:** Skip with "No dev server detected (checked AGENTS.md, the plan, and common ports) — skipping plan verification. Run /qa separately after deploying, or document the dev URL in AGENTS.md so this step finds it next time."
### 3. Invoke /qa-only inline
@@ -2579,6 +2652,8 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS"
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub) or `glab mr update -d ...` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. **Run the same redaction scan-at-sink (PR body + title) as the create path (Step 19) before editing — scan the temp file, then `gh pr edit --body-file` from it.**
**REST fallback (#1079):** on some repos `gh pr edit` hard-errors with a GraphQL deprecation mentioning `repository.pullRequest.projectCards` ("Projects (classic) is being deprecated..."). That is a `gh` GraphQL-path problem, not a permissions problem — do not re-ask for auth. Fall back to the REST endpoint, which never touches the deprecated field, using the SAME already-scanned temp file: `PR_NUMBER=$(gh pr view --json number -q .number)` then `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -F body=@"$PR_BODY_FILE"` for the body, and `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -f title="$NEW_TITLE"` when the title edit below hits the same error. Verify with the same self-checks as the primary path.
**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule.
1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`).
+98 -23
View File
@@ -666,6 +666,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
@@ -796,6 +800,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
## Third-Party Web Actions
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
## Step 0: Detect platform and base branch
First, detect the git hosting platform from the remote URL:
@@ -880,6 +898,18 @@ Never skip a verification step because a prior `/ship` run already performed it.
---
## Step 0.9: Apple target detection
Shipping to the App Store is not landing a PR. If the repository contains an
`.xcodeproj`, `.xcworkspace`, or a Swift package with an app product AND the
user's ask is store distribution (App Store, TestFlight, "release my app"),
**STOP and Read `$GSTACK_ROOT/ship/sections/apple-release.md` FIRST**
— before the branch gate and any preflight below. Store distribution proceeds
from whatever branch the user is on (a clean tree on the base branch is the
solo developer's normal case, not an error) and follows the adapter end to
end. The branch gate and repository-landing pipeline below apply ONLY to
repository-landing asks, including on Apple repos.
## Step 1: Pre-flight
1. Check the current branch. If on the base branch or the repo's default branch, **abort**: "You're on the base branch. Ship from a feature branch."
@@ -1001,41 +1031,70 @@ git fetch origin <base> && git merge origin/<base> --no-edit
## Test Framework Bootstrap
**Detect existing test framework and project runtime:**
**Read the project's CLAUDE.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5.
**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse.
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
# Detect project runtime
[ -f Gemfile ] && echo "RUNTIME:ruby"
# Definitive ecosystem markers (presence = ecosystem, NOT a command to run)
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py"
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
[ -f package.json ] && echo "RUNTIME:node"
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
[ -f go.mod ] && echo "RUNTIME:go"
[ -f Cargo.toml ] && echo "RUNTIME:rust"
[ -f composer.json ] && echo "RUNTIME:php"
[ -f mix.exs ] && echo "RUNTIME:elixir"
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
# Detect sub-frameworks
[ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails"
[ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs"
# Check for existing test infrastructure
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
# Existing test path — config files, declared scripts, AND test FILES.
# A project with real tests and no config file is the common miss.
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest"
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
# Rust keeps unit tests inside src/, so file names alone miss them
[ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source"
# Check opt-out marker
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"
```
**If test framework detected** (config files or test directories found):
Print "Test framework detected: {name} ({N} existing tests). Skipping bootstrap."
Map the markers to the command you will OFFER — never to one you run on a guess:
| Marker | Ecosystem | Candidate command to offer |
|--------|-----------|----------------------------|
| `manage.py` | Django | `python manage.py test` (or `pytest` when pytest-django is in the deps) |
| `pytest.ini` / `tox.ini` / pytest in `pyproject.toml` / `test_*.py` | Python | `pytest` |
| `go.mod` (+ any `*_test.go`) | Go | `go test ./...` |
| `Cargo.toml` | Rust | `cargo test` |
| `pom.xml` | JVM (Maven) | `mvn test` |
| `build.gradle` / `build.gradle.kts` | JVM (Gradle) | `./gradlew test` |
| `Gemfile` / `Rakefile` / `.rspec` | Ruby | `bundle exec rspec`, `bin/rails test`, or `rake test` |
| `mix.exs` | Elixir | `mix test` |
| `composer.json` | PHP | `composer test` or `./vendor/bin/phpunit` |
| `package.json` with a `test` script | Node | that script, run with the package manager the lockfile names |
| `Makefile` with a `test:` target | any | `make test` |
**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — CLAUDE.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to CLAUDE.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one.
Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns).
Store conventions as prose context for use in Phase 8e.5 or Step 7. **Skip the rest of bootstrap.**
Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `<app>/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. A green `python manage.py test` with no `pytest.ini` is a tested project, not a bootstrap candidate.
**If BOOTSTRAP_DECLINED** appears: Print "Test bootstrap previously declined — skipping." **Skip the rest of bootstrap.**
**If NO runtime detected** (no config files found): Use AskUserQuestion:
**If NO ecosystem marker matched:** Use AskUserQuestion:
"I couldn't detect your project's language. What runtime are you using?"
Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests.
If the runtime you need isn't listed, offer "Other" and take the runtime plus the test command as free text.
If user picks H → write `.gstack/no-test-bootstrap` and continue without tests.
**If runtime detected but no test framework — bootstrap:**
**If an ecosystem matched but there is no existing-test evidence at all — bootstrap:**
### B2. Research best practices
@@ -1051,7 +1110,9 @@ If WebSearch is unavailable, use this built-in knowledge table:
| Node.js | vitest + @testing-library | jest + @testing-library |
| Next.js | vitest + @testing-library/react + playwright | jest + cypress |
| Python | pytest + pytest-cov | unittest |
| Django | pytest + pytest-django | Django's built-in `manage.py test` (unittest) |
| Go | stdlib testing + testify | stdlib only |
| JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only |
| Rust | cargo test (built-in) + mockall | — |
| PHP | phpunit + mockery | pest |
| Elixir | ExUnit (built-in) + ex_machina | — |
@@ -1380,15 +1441,20 @@ Before analyzing coverage, detect the project's test framework:
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
# Detect project runtime
[ -f Gemfile ] && echo "RUNTIME:ruby"
# Detect project runtime (markers are evidence, not commands to run blind)
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django"
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
[ -f package.json ] && echo "RUNTIME:node"
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
[ -f go.mod ] && echo "RUNTIME:go"
[ -f Cargo.toml ] && echo "RUNTIME:rust"
# Check for existing test infrastructure
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini phpunit.xml 2>/dev/null
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
# Check for existing test infrastructure — config files, scripts, AND test files
ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml 2>/dev/null
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
```
3. **If no framework detected:** falls through to the Test Framework Bootstrap step (Step 4) which handles full setup.
@@ -1820,16 +1886,23 @@ Using the plan file already discovered in Step 8, look for a verification sectio
### 2. Check for running dev server
Before invoking browse-based verification, check if a dev server is reachable:
Before invoking browse-based verification, find the dev-server URL the way the
project declares it — never trust a hardcoded port list alone:
1. **CLAUDE.md first:** look for a documented dev URL or dev command (a
`## Development`/`## Testing` section naming a port or URL). Use it.
2. **The plan file:** if the plan's verification section names a URL, use it.
3. **Fallback probe** (common ports, only when 1-2 found nothing):
```bash
curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 2>/dev/null || \
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080 2>/dev/null || \
curl -s -o /dev/null -w '%{http_code}' http://localhost:5173 2>/dev/null || \
curl -s -o /dev/null -w '%{http_code}' http://localhost:4000 2>/dev/null || echo "NO_SERVER"
for _p in 3000 8080 5173 4000 4321 8000; do
_code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$_p" 2>/dev/null)
[ -n "$_code" ] && [ "$_code" != "000" ] && { echo "DEV_SERVER: http://localhost:$_p ($_code)"; break; }
done
[ -z "${_code:-}" ] || [ "${_code:-000}" = "000" ] && echo "NO_SERVER"
```
**If NO_SERVER:** Skip with "No dev server detected — skipping plan verification. Run /qa separately after deploying."
**If NO_SERVER:** Skip with "No dev server detected (checked CLAUDE.md, the plan, and common ports) — skipping plan verification. Run /qa separately after deploying, or document the dev URL in CLAUDE.md so this step finds it next time."
### 3. Invoke /qa-only inline
@@ -2995,6 +3068,8 @@ glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS"
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body-file "$PR_BODY_FILE"` (GitHub) or `glab mr update -d ...` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. **Run the same redaction scan-at-sink (PR body + title) as the create path (Step 19) before editing — scan the temp file, then `gh pr edit --body-file` from it.**
**REST fallback (#1079):** on some repos `gh pr edit` hard-errors with a GraphQL deprecation mentioning `repository.pullRequest.projectCards` ("Projects (classic) is being deprecated..."). That is a `gh` GraphQL-path problem, not a permissions problem — do not re-ask for auth. Fall back to the REST endpoint, which never touches the deprecated field, using the SAME already-scanned temp file: `PR_NUMBER=$(gh pr view --json number -q .number)` then `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -F body=@"$PR_BODY_FILE"` for the body, and `gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" -X PATCH -f title="$NEW_TITLE"` when the title edit below hits the same error. Verify with the same self-checks as the primary path.
**Always update the PR title to start with `v$NEW_VERSION`.** PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first, no exceptions, no "custom title kept intentionally" escape hatch. The shared helper `bin/gstack-pr-title-rewrite.sh` is the single source of truth for the rule.
1. Read the current title: `CURRENT=$(gh pr view --json title -q .title)` (or `glab mr view -F json | jq -r .title`).
@@ -97,10 +97,12 @@ public final class StateServer {
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
// 2. Log the boot token EXACTLY ONCE so the daemon can scrape it.
// The daemon will rotate immediately; this log line is dead within
// seconds.
logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
// 2. Announce bootstrap WITHOUT the token. The daemon reads the boot
// token from the 0600 file above (copyFileFromAppContainer); the
// os_log line that used to carry it had no consumer and handed a
// live credential to anything reading the unified log during the
// launch window. Port/build stay for diagnostics.
logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
// 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
@@ -149,7 +151,19 @@ public final class StateServer {
let params = NWParameters.tcp
params.allowLocalEndpointReuse = true
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
// IPv4 has no CoreDevice tunnel path, so it binds strictly to
// loopback at the socket level; IPv6 keeps the wildcard bind and
// relies on the per-connection peer check below for tunnel peers.
let listener: NWListener
switch family {
case .ipv4:
params.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host("127.0.0.1"),
port: NWEndpoint.Port(rawValue: port)!)
listener = try NWListener(using: params)
case .ipv6:
listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
}
listener.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
if case .ready = state {
+77
View File
@@ -0,0 +1,77 @@
/**
* #538: the office-hours founder-resources pitch takes no for an answer.
*
* The reporter found that memory instructions telling the agent to stop
* showing the 34-resource pool kept being overridden on every update. The
* fix is a config key `founder_resources` that session context cannot
* override: `false` skips the entire section silently, forever, until the
* user re-enables it.
*
* Pins: (1) the config key's default, persistence, and validation through
* the real bin/gstack-config subprocess; (2) the generated section gates on
* the key BEFORE any resource content; (3) the opt-out write is verified
* before the skill may promise "never again" (R6 a failed write must not
* produce a false promise).
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { execFileSync } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
const ROOT = path.resolve(import.meta.dir, "..");
const CONFIG_BIN = path.join(ROOT, "bin", "gstack-config");
const SECTION = path.join(ROOT, "office-hours", "sections", "design-and-handoff.md");
let tmpHome: string;
beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-538-")); });
afterEach(() => { fs.rmSync(tmpHome, { recursive: true, force: true }); });
function cfg(args: string[]): string {
return execFileSync(CONFIG_BIN, args, {
encoding: "utf-8",
env: { ...process.env, GSTACK_HOME: tmpHome },
}).trim();
}
describe("founder_resources config key (#538)", () => {
test("defaults to true (pitch stays on for everyone who never opted out)", () => {
expect(cfg(["get", "founder_resources"])).toBe("true");
});
test("set false persists and reads back — the write-verify contract", () => {
cfg(["set", "founder_resources", "false"]);
expect(cfg(["get", "founder_resources"])).toBe("false");
});
test("invalid values are rejected to the default, never persisted as-is", () => {
execFileSync(CONFIG_BIN, ["set", "founder_resources", "banana"], {
encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, GSTACK_HOME: tmpHome },
});
expect(cfg(["get", "founder_resources"])).toBe("true");
});
});
describe("office-hours section gates on the key (#538)", () => {
const src = fs.readFileSync(SECTION, "utf-8");
test("the opt-out check precedes any resource content", () => {
const gate = src.indexOf("gstack-config get founder_resources");
const pool = src.indexOf("Resource Pool");
expect(gate).toBeGreaterThan(-1);
expect(pool).toBeGreaterThan(-1);
expect(gate).toBeLessThan(pool);
});
test("skip is silent and permanent — never means never", () => {
expect(src).toContain("skip this entire section silently");
expect(src).toContain("gstack-config set founder_resources true");
});
test("the opt-out write is verified before any promise (R6)", () => {
expect(src).toContain("VERIFY the write");
expect(src).toMatch(/read back\s*\n?`false`/);
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* CI secret gate contract (R4/R9, fork port wave 2).
*
* .github/scripts/gate-secret-scan.mjs pipes a unified diff's ADDED lines
* into bin/gstack-redact and enforces: HIGH fails (exit 1), MEDIUM is an
* advisory count only (no human in CI to confirm, so it must never fail
* the check), clean passes. The workflow-level pathspec excludes keep the
* planted-bug fixtures out of the diff entirely; this pins the script's
* own exit contract with live subprocess runs.
*/
import { describe, test, expect } from "bun:test";
import { spawnSync } from "child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
const ROOT = join(import.meta.dir, "..");
const SCRIPT = join(ROOT, ".github", "scripts", "gate-secret-scan.mjs");
function scan(diff: string, cwd: string = ROOT): { code: number; out: string } {
const res = spawnSync("node", [SCRIPT], {
cwd,
input: diff,
encoding: "utf-8",
timeout: 60_000,
});
return { code: res.status ?? -1, out: `${res.stdout}${res.stderr}` };
}
describe("gate-secret-scan.mjs exit contract", () => {
test("clean added lines pass", () => {
const r = scan("+const x = 1;\n+++ b/file.ts\n+// harmless\n");
expect(r.code).toBe(0);
expect(r.out).toContain("0 high");
});
// The planted PEM is assembled at runtime — header split included — so this
// FILE never carries a live-format private key: the repo's own prepush
// credential guard scans pushed diffs and (correctly) blocks any one-line
// BEGIN…END spelling regardless of body. The scanner under test still
// receives the true live shape.
const PEM_BEGIN = ["-----BEGIN RSA ", "PRIVATE KEY-----"].join("");
const PEM_END = ["-----END RSA ", "PRIVATE KEY-----"].join("");
const PLANTED_PEM_BODY = ["MIIEow", "IBAAKC", "AQEA"].join("");
test("a HIGH credential in an added line fails the gate", () => {
const r = scan(
`+${PEM_BEGIN}\n+${PLANTED_PEM_BODY}\n+${PEM_END}\n`,
);
expect(r.code).toBe(1);
expect(r.out).toContain("1 high");
});
test("removed lines and context are ignored — only additions are scanned", () => {
const r = scan(
`-${PEM_BEGIN}\n-${PLANTED_PEM_BODY}\n${PEM_END}\n+just an addition\n`,
);
expect(r.code).toBe(0);
});
test("MEDIUM findings are advisory only — never fail CI", () => {
// A Stripe publishable-key shape sits at MEDIUM in the taxonomy
// (context-variable; a human confirms interactively, CI cannot).
const r = scan(`+const key = "pk_live_${"a".repeat(24)}";\n`);
expect(r.code).toBe(0);
expect(r.out).toMatch(/\d+ advisory/);
});
});
describe("gate-secret-scan.mjs fail-closed legs", () => {
test("oversize diff (report.oversize) fails the gate", () => {
// The script pins --max-bytes 16000000; bin/gstack-redact refuses to scan
// anything larger and reports oversize:true (fail-closed). The gate must
// exit 1 rather than pass unscanned bytes. ~17MB of added lines guarantees
// the joined additions exceed the cap.
const line = `+${"a".repeat(8190)}\n`;
const r = scan(line.repeat(2100));
expect(r.code).toBe(1);
// Proves the failure came from the parsed report (the engine surfaces
// oversize as a fail-closed HIGH), not from a crashed subprocess.
expect(r.out).toContain("1 high");
}, 60_000);
test("unexpected gstack-redact exit code fails the gate even when the report is clean", () => {
// Stub bin/gstack-redact that emits a CLEAN JSON report but exits 1 —
// not one of the contract codes (0 clean / 2 MEDIUM / 3 HIGH). The gate
// must treat the unexpected exit as failure: a broken scanner reporting
// "all clear" is exactly the fail-open shape this leg guards against.
const dir = mkdtempSync(join(tmpdir(), "gate-secret-scan-stub-"));
try {
mkdirSync(join(dir, "bin"));
writeFileSync(
join(dir, "bin", "gstack-redact"),
[
"#!/usr/bin/env bun",
'let input = "";',
'process.stdin.setEncoding("utf8");',
'process.stdin.on("data", (c) => { input += c; });',
'process.stdin.on("end", () => {',
' console.log(JSON.stringify({ findings: [], counts: { HIGH: 0, MEDIUM: 0, LOW: 0, WARN: 0 }, repoVisibility: "public", oversize: false }));',
" process.exit(1);",
"});",
"",
].join("\n"),
);
const r = scan("+const x = 1;\n", dir);
expect(r.out).toContain("0 high"); // the clean report WAS parsed...
expect(r.code).toBe(1); // ...and the gate still failed on the exit code
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("missing gstack-redact (spawn crash, empty stdout) exits nonzero — never fail-open", () => {
// cwd with no bin/gstack-redact at all: bun exits module-not-found with
// empty stdout. Whatever the exact failure shape, the gate must not
// report success.
const dir = mkdtempSync(join(tmpdir(), "gate-secret-scan-absent-"));
try {
const r = scan("+const x = 1;\n", dir);
expect(r.code).not.toBe(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+111 -111
View File
@@ -24,6 +24,7 @@ import {
mkdirSync,
writeFileSync,
readFileSync,
existsSync,
rmSync,
chmodSync,
} from "fs";
@@ -39,24 +40,6 @@ interface FakeEnv {
cleanup: () => void;
}
interface ShellCase {
name: "bash" | "zsh";
command: string;
available: boolean;
}
function shellAvailable(command: string): boolean {
const result = spawnSync(command, ["-c", "exit 0"], {
stdio: "ignore",
});
return result.status === 0;
}
const SHELLS: ShellCase[] = [
{ name: "bash", command: "bash", available: shellAvailable("bash") },
{ name: "zsh", command: "zsh", available: shellAvailable("zsh") },
];
function makeFakeEnv(): FakeEnv {
const tmp = mkdtempSync(join(tmpdir(), "gbrain-voyage-init-"));
const home = join(tmp, "home");
@@ -69,13 +52,8 @@ function makeFakeEnv(): FakeEnv {
// succeeds on init (writes a sentinel pglite config), and returns canned
// output for --version. Nothing else is needed for the shape test.
const fake = `#!/bin/sh
{
echo "__CALL__"
for arg in "$@"; do
printf 'arg=%s\\n' "$arg"
done
echo "__END__"
} >> "${argvLog}"
echo "$@" >> "${argvLog}"
echo "$#" >> "${argvLog}.argc"
case "$1" in
--version)
echo "gbrain 0.37.1.0"
@@ -104,11 +82,11 @@ exit 0
}
/**
* Verbatim reimplementation of the skill template's voyage-code-3 conditional.
* The template (setup-gbrain/SKILL.md.tmpl Path 3, Step 1.5 inside the
* rollback wrapper, Step 4.5 Path 4 Yes branch) instructs the model to execute
* this shell; we execute the same shell here and assert the argv passed to
* gbrain matches the contract under both bash and zsh.
* Verbatim reimplementation of the skill template's voyage-code-3
* conditional. The template (setup-gbrain/SKILL.md.tmpl Path 3, Step 1.5
* inside the rollback wrapper, Step 4.5 Path 4 Yes branch) instructs the
* model to execute this bash; we execute the same bash here and assert the
* argv passed to gbrain matches the contract.
*
* If the template changes the flag set or the env-var name, this test
* should fail until the shell here is updated too by design.
@@ -116,15 +94,19 @@ exit 0
function runInitWithVoyageGate(
env: FakeEnv,
voyageKey: string | undefined,
shell: ShellCase,
): string[][] {
shell: "bash" | "zsh" = "bash",
): string[] {
// The template's #1798 shape: flags ride the positional params, because an
// unquoted $VAR does NOT word-split under zsh — the whole flag string
// arrived as ONE argv word and gbrain silently fell back to its default
// embedding model.
const script = `
set -u
set --
if [ -n "\${VOYAGE_API_KEY:-}" ]; then
gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
else
gbrain init --pglite --json
set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024
fi
gbrain init --pglite --json "$@"
`;
const baseEnv: Record<string, string> = {
...process.env,
@@ -136,86 +118,111 @@ fi
} else {
baseEnv.VOYAGE_API_KEY = voyageKey;
}
const result = spawnSync(shell.command, ["-c", script], {
const result = spawnSync(shell, ["-c", script], {
encoding: "utf-8",
env: baseEnv,
});
if (result.status !== 0) {
throw new Error(
`${shell.name} init script exited ${result.status}: ${result.stderr}`,
);
throw new Error(`init script exited ${result.status}: ${result.stderr}`);
}
return parseArgvLog(env.argvLog);
return readFileSync(env.argvLog, "utf-8").trim().split("\n");
}
function parseArgvLog(argvLog: string): string[][] {
const calls: string[][] = [];
let current: string[] | undefined;
for (const line of readFileSync(argvLog, "utf-8").trim().split("\n")) {
if (line === "__CALL__") {
current = [];
continue;
}
if (line === "__END__") {
if (current) calls.push(current);
current = undefined;
continue;
}
if (current && line.startsWith("arg=")) {
current.push(line.slice("arg=".length));
}
}
return calls;
function lastArgc(env: FakeEnv): number {
const lines = readFileSync(`${env.argvLog}.argc`, "utf-8").trim().split("\n");
return parseInt(lines[lines.length - 1], 10);
}
const HAVE_ZSH = spawnSync("zsh", ["-c", "true"]).status === 0;
describe("voyage-code-3 default for gstack-driven PGLite init", () => {
for (const shell of SHELLS) {
const describeShell = shell.available ? describe : describe.skip;
it("passes voyage-code-3 flags when VOYAGE_API_KEY is set", () => {
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, "vk_test_set");
expect(calls.length).toBe(1);
const argv = calls[0];
expect(argv).toContain("init --pglite --json");
expect(argv).toContain("--embedding-model voyage:voyage-code-3");
expect(argv).toContain("--embedding-dimensions 1024");
} finally {
env.cleanup();
}
});
describeShell(`under ${shell.name}`, () => {
it("passes voyage-code-3 flags as four separate argv entries when VOYAGE_API_KEY is set", () => {
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, "vk_test_set", shell);
expect(calls).toEqual([
[
"init",
"--pglite",
"--json",
"--embedding-model",
"voyage:voyage-code-3",
"--embedding-dimensions",
"1024",
],
]);
} finally {
env.cleanup();
}
});
it("omits voyage flags when VOYAGE_API_KEY is unset", () => {
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, undefined);
expect(calls.length).toBe(1);
const argv = calls[0];
expect(argv).toContain("init --pglite --json");
expect(argv).not.toContain("voyage");
expect(argv).not.toContain("--embedding-model");
expect(argv).not.toContain("--embedding-dimensions");
} finally {
env.cleanup();
}
});
it("omits voyage flags when VOYAGE_API_KEY is unset", () => {
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, undefined, shell);
expect(calls).toEqual([["init", "--pglite", "--json"]]);
} finally {
env.cleanup();
}
});
it("zsh: flags arrive as SEPARATE argv words (#1798 — the shell that broke)", () => {
if (!HAVE_ZSH) return; // zsh ships on macOS; skip quietly elsewhere
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, "vk_test_set", "zsh");
expect(calls.length).toBe(1);
expect(calls[0]).toContain("--embedding-model voyage:voyage-code-3");
// init --pglite --json + 4 flag words = 7 argv entries. The pre-#1798
// unquoted-var shape produced 4 under zsh (the whole flag string as one
// word), and gbrain silently fell back to its default embedding model.
expect(lastArgc(env)).toBe(7);
} finally {
env.cleanup();
}
});
it("treats empty-string VOYAGE_API_KEY the same as unset (no false positive)", () => {
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, "", shell);
expect(calls).toEqual([["init", "--pglite", "--json"]]);
} finally {
env.cleanup();
}
it("demonstrates the #1798 collision: an unquoted flags var is ONE word under zsh", () => {
if (!HAVE_ZSH) return;
const env = makeFakeEnv();
try {
const brokenShape = `
set -u
GBRAIN_EMBED_FLAGS="--embedding-model voyage:voyage-code-3 --embedding-dimensions 1024"
gbrain init --pglite --json $GBRAIN_EMBED_FLAGS
`;
const result = spawnSync("zsh", ["-c", brokenShape], {
encoding: "utf-8",
env: { ...process.env, HOME: env.home, PATH: `${env.bindir}:/usr/bin:/bin` },
});
});
}
expect(result.status).toBe(0);
expect(lastArgc(env)).toBe(4); // init, --pglite, --json, "<entire flag string>"
} finally {
env.cleanup();
}
});
it("template uses the positional-params shape, not an unquoted flags var", () => {
const tmpl = readFileSync(
join(import.meta.dir, "..", "setup-gbrain", "SKILL.md.tmpl"),
"utf-8",
);
expect(tmpl).not.toContain("$GBRAIN_EMBED_FLAGS");
const sites = tmpl.match(/gbrain init --pglite --json "\$@"/g) || [];
expect(sites.length).toBe(3);
const setSites = tmpl.match(/set -- --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024/g) || [];
expect(setSites.length).toBe(3);
});
it("treats empty-string VOYAGE_API_KEY the same as unset (no false positive)", () => {
const env = makeFakeEnv();
try {
const calls = runInitWithVoyageGate(env, "");
expect(calls.length).toBe(1);
expect(calls[0]).not.toContain("voyage");
} finally {
env.cleanup();
}
});
});
describe("template alignment: the .tmpl actually contains the voyage gate", () => {
@@ -225,13 +232,11 @@ describe("template alignment: the .tmpl actually contains the voyage gate", () =
const TEMPLATE_PATH = join(import.meta.dir, "..", "setup-gbrain", "SKILL.md.tmpl");
const tmpl = readFileSync(TEMPLATE_PATH, "utf-8");
it("setup-gbrain template gates the embedding-model flag on VOYAGE_API_KEY without word-splitting", () => {
it("setup-gbrain template gates the embedding-model flag on VOYAGE_API_KEY", () => {
// Should appear at least once (currently 3 init sites use the same gate).
expect(tmpl).toContain('if [ -n "${VOYAGE_API_KEY:-}" ]; then');
expect(tmpl).toContain(
"gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024",
);
expect(tmpl).not.toContain("GBRAIN_EMBED_FLAGS");
expect(tmpl).toContain("--embedding-model voyage:voyage-code-3");
expect(tmpl).toContain("--embedding-dimensions 1024");
});
it("setup-gbrain template uses the conditional gate at all 3 PGLite init sites", () => {
@@ -239,10 +244,5 @@ describe("template alignment: the .tmpl actually contains the voyage gate", () =
// init site, update this expectation deliberately.
const matches = tmpl.match(/if \[ -n "\$\{VOYAGE_API_KEY:-\}" \]; then/g);
expect(matches?.length).toBe(3);
const voyageInitMatches = tmpl.match(
/gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024/g,
);
expect(voyageInitMatches?.length).toBe(3);
});
});
+102
View File
@@ -269,3 +269,105 @@ describe('get without arg (auto-detect from current dir)', () => {
}
});
});
// ── #2140 sync-path chokepoint ──────────────────────────────────────────────
// The tier above is a STORE. This block pins the ENFORCEMENT: a direct
// gstack-gbrain-sync invocation (skill prose bypassed — cron, curiosity,
// automation) must honor deny/read-only at the code-import stage, and the
// egress receipt's "per-repo policy chokepoint (repoPolicyTier)" consent
// string must describe code that exists. Wave-1 shipped the receipt string
// without the function; these tests make that impossible to repeat.
describe('gstack-gbrain-sync code stage honors the repo policy (#2140 sync path)', () => {
const SYNC = path.join(ROOT, 'bin', 'gstack-gbrain-sync.ts');
const REPO_URL = 'https://github.com/acme/widget.git';
let repoDir: string;
function makeRepo(): void {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-policy-repo-'));
const git = (...args: string[]) =>
spawnSync('git', args, { cwd: repoDir, encoding: 'utf-8' });
git('init', '-q', '.');
git('remote', 'add', 'origin', REPO_URL);
fs.writeFileSync(path.join(repoDir, 'README.md'), 'fixture\n');
git('add', '-A');
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture');
}
function runSync(): { status: number; text: string; stages: any[] } {
const res = spawnSync('bun', [SYNC, '--code-only', '--incremental'], {
cwd: repoDir,
encoding: 'utf-8',
timeout: 60_000,
// HOME also redirected so engine detection can't find a real ~/.gbrain.
env: { ...process.env, GSTACK_HOME: tmpHome, HOME: tmpHome },
});
let stages: any[] = [];
try {
stages = JSON.parse(
fs.readFileSync(path.join(tmpHome, '.gbrain-sync-state.json'), 'utf-8'),
).last_stages || [];
} catch {
// state file may be absent on early refusal paths — text asserts cover it
}
return {
status: res.status ?? -1,
text: `${res.stdout || ''}\n${res.stderr || ''}`,
stages,
};
}
afterEach(() => {
if (repoDir) fs.rmSync(repoDir, { recursive: true, force: true });
});
test('deny → code stage refuses loudly, exit 1, status refused-policy-deny', () => {
makeRepo();
expect(run(['set', REPO_URL, 'deny']).status).toBe(0);
const r = runSync();
expect(r.status).toBe(1);
expect(r.text).toContain('refused');
expect(r.text).toContain('deny');
const code = r.stages.find((s: any) => s.name === 'code');
expect(code?.detail?.status).toBe('refused-policy-deny');
});
test('read-only → clean skip (exit 0), status skipped-policy-read-only', () => {
makeRepo();
expect(run(['set', REPO_URL, 'read-only']).status).toBe(0);
const r = runSync();
expect(r.status).toBe(0);
expect(r.text).toContain('read-only');
const code = r.stages.find((s: any) => s.name === 'code');
expect(code?.detail?.status).toBe('skipped-policy-read-only');
});
test('store exists but unreadable → fail-closed refusal, never bypassed', () => {
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod semantics differ
makeRepo();
expect(run(['set', REPO_URL, 'deny']).status).toBe(0);
fs.chmodSync(policyFile(), 0o000);
try {
const r = runSync();
expect(r.status).toBe(1);
expect(r.text).toContain('refus');
const code = r.stages.find((s: any) => s.name === 'code');
expect(code?.detail?.status).toBe('refused-policy-unreadable');
} finally {
fs.chmodSync(policyFile(), 0o600);
}
});
test('no policy store → fail-open, stage proceeds past the gate (no policy status)', () => {
makeRepo();
const r = runSync();
const code = r.stages.find((s: any) => s.name === 'code');
// With no engine in the redirected HOME the stage skips for ENGINE
// reasons — what matters is that no policy refusal fired and the exit
// is clean, preserving pre-policy behavior for every non-policy user.
expect(r.status).toBe(0);
expect(String(code?.detail?.status || '')).not.toContain('policy');
expect(r.text).not.toContain('refused');
});
});
+5 -2
View File
@@ -1079,8 +1079,11 @@ describe('PLAN_VERIFICATION_EXEC placeholder', () => {
expect(shipSkill).toContain('qa-only');
});
test('contains localhost reachability check', () => {
expect(shipSkill).toContain('localhost:3000');
test('contains dev-server discovery (CLAUDE.md first, then a port probe)', () => {
// Fork port wave 2: the hardcoded 4-port list became read-CLAUDE.md-or-
// probe; the probe loops common ports instead of naming each once.
expect(shipSkill).toContain('CLAUDE.md first');
expect(shipSkill).toContain('http://localhost:$_p');
expect(shipSkill).toContain('NO_SERVER');
});
+144 -2
View File
@@ -7,7 +7,7 @@
*/
import { describe, it, expect } from "bun:test";
import { chmodSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
import { chmodSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
import { tmpdir } from "os";
import { delimiter, join } from "path";
import { spawnSync } from "child_process";
@@ -52,10 +52,47 @@ fi
chmodSync(fakeBin, 0o755);
}
/**
* Like writeFakeGbrain, but every invocation appends its argv to `logFile`.
* Still answers `--version` successfully ON PURPOSE: a revert from the
* memoized PATH stat scan back to the old `gbrain --version` spawn probe
* would pass every non-logging test only the argv log catches it.
*/
function writeLoggingGbrain(binDir: string, logFile: string): void {
if (process.platform === "win32") {
writeFileSync(
join(binDir, "gbrain.cmd"),
`@echo off\r\necho %* >> "${logFile}"\r\nif "%1"=="--version" (\r\n echo gbrain 0.test\r\n) else (\r\n echo fake gbrain %*\r\n)\r\n`,
"utf-8",
);
return;
}
const fakeBin = join(binDir, "gbrain");
writeFileSync(
fakeBin,
`#!/bin/sh
printf '%s\\n' "$*" >> "${logFile}"
if [ "$1" = "--version" ]; then
echo "gbrain 0.test"
else
echo "fake gbrain $*"
fi
`,
"utf-8",
);
chmodSync(fakeBin, 0o755);
}
function prependPath(binDir: string): Record<string, string> {
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "PATH";
const currentPath = process.env[pathKey] || "";
return { [pathKey]: `${binDir}${delimiter}${currentPath}` };
return {
[pathKey]: `${binDir}${delimiter}${currentPath}`,
// Cold process spawns on a loaded machine can exceed the 500ms default
// budget; the fake gbrain is instant once spawned, so give it headroom.
GSTACK_BRAIN_TIMEOUT_MS: "10000",
};
}
describe("gstack-brain-context-load CLI", () => {
@@ -252,6 +289,111 @@ describe("gstack-brain-context-load — graceful gbrain absence", () => {
}
});
it("manifest filter: blocks reach gbrain as --filter args with template vars resolved (#1687)", () => {
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
const binDir = join(dir, "bin");
mkdirSync(binDir);
writeFakeGbrain(binDir);
const skillFile = join(dir, "SKILL.md");
writeFileSync(
skillFile,
`---
name: x
gbrain:
schema: 1
context_queries:
- id: prior-sessions
kind: list
filter:
type: ceo-plan
tags_contains: "repo:{repo_slug}"
sort: updated_at_desc
limit: 5
render_as: "## Prior sessions"
---
`,
"utf-8"
);
try {
const r = runScript(["--skill-file", skillFile, "--repo", "my-test-repo"], prependPath(binDir));
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain("fake gbrain list_pages");
expect(r.stdout).toContain("--filter type=ceo-plan");
expect(r.stdout).toContain("--filter tags_contains=repo:my-test-repo");
expect(r.stdout).toContain("--sort updated_at_desc");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("gbrain detection never spawns gbrain — stat-based PATH scan, not a `--version` probe (revert trap)", () => {
// The fix replaced a per-query `gbrain --version` spawn probe with a
// memoized PATH stat scan. The plain writeFakeGbrain shim still ANSWERS
// --version, so a revert to the spawn probe passes every other test in
// this file. This fake logs its argv: detection must invoke gbrain zero
// times, so the only invocations are the 3 default-manifest list_pages
// queries — a revert adds `--version` lines (and re-probing adds one per
// query) and fails exactly here.
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
const binDir = join(dir, "bin");
mkdirSync(binDir);
const logFile = join(dir, "gbrain-argv.log");
writeLoggingGbrain(binDir, logFile);
try {
const r = runScript(["--repo", "test-repo", "--explain", "--quiet"], prependPath(binDir));
expect(r.exitCode).toBe(0);
expect(r.stderr).toContain("queries=3");
const invocations = readFileSync(logFile, "utf-8").split("\n").filter(Boolean);
expect(invocations.some((argv) => argv.includes("--version"))).toBe(false);
// Exactly the 3 real queries — no extra availability spawns of any shape.
expect(invocations).toHaveLength(3);
for (const argv of invocations) expect(argv.startsWith("list_pages")).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("availability survives a 1ms query budget — detection is not subject to GSTACK_BRAIN_TIMEOUT_MS", () => {
// The spawn probe ran under the same MCP_TIMEOUT_MS budget as the queries,
// so a cold spawn slower than the budget misreported gbrain as MISSING.
// With the stat scan, a 1ms budget kills the queries themselves (SKIP)
// but detection still sees the CLI — "gbrain CLI missing" must not appear.
const dir = mkdtempSync(join(tmpdir(), "gstack-bcl-"));
const binDir = join(dir, "bin");
mkdirSync(binDir);
// A SLOW fake, not the shared instant one: on a fast CI runner the
// instant fake answered inside even a 1ms budget (observed dur=0ms on
// ubicloud) and no SKIP ever printed. Sleeping makes the timeout
// deterministic on every machine; --version stays instant so detection
// has nothing to wait on.
const fakeBin = join(binDir, "gbrain");
writeFileSync(
fakeBin,
`#!/bin/sh
if [ "$1" = "--version" ]; then
echo "gbrain 0.test"
else
sleep 0.3
echo "fake gbrain $*"
fi
`,
"utf-8",
);
chmodSync(fakeBin, 0o755);
try {
const env = { ...prependPath(binDir), GSTACK_BRAIN_TIMEOUT_MS: "1" };
const r = runScript(["--repo", "test-repo", "--explain", "--quiet"], env);
expect(r.exitCode).toBe(0);
expect(r.stderr).toContain("SKIP");
expect(r.stderr).not.toContain("gbrain CLI missing");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("vector + list queries still complete (with SKIP) when gbrain CLI is missing", () => {
// We can't easily un-install gbrain; rely on the helper's own missing-binary
// detection. The default manifest uses kind: list which calls gbrain. If
+33
View File
@@ -63,6 +63,39 @@ describe("gstack-decision-log", () => {
const r = log("not json", true);
expect(r.code).toBe(1);
});
test("--supersede with a replacement body records the replacement, linked to the old id", () => {
const id = log('{"decision":"old-call","scope":"repo","source":"user"}').out;
const out = logFlag(
`--supersede ${id} '{"decision":"new-call","rationale":"better","scope":"repo","source":"user"}'`,
);
expect(out).toContain(id);
expect(search()).toContain("new-call"); // the replacement is NOT silently dropped
expect(search()).not.toContain("old-call");
const arr = JSON.parse(search("--json"));
expect(arr.find((d: any) => d.decision === "new-call")?.supersedes).toBe(id);
});
test("--supersede with an INVALID replacement persists nothing (old stays active)", () => {
const id = log('{"decision":"keep-me","scope":"repo","source":"user"}').out;
let code = 0;
try {
logFlag(`--supersede ${id} '{"decision":""}'`);
} catch (e: any) {
code = e.status || 1;
}
expect(code).toBe(1);
expect(search()).toContain("keep-me"); // not retired by a failed replacement
});
test("--redact refuses a replacement body instead of dropping it", () => {
const id = log('{"decision":"redact-target","scope":"repo","source":"user"}').out;
let code = 0;
try {
logFlag(`--redact ${id} '{"decision":"would-be-lost","scope":"repo","source":"user"}'`);
} catch (e: any) {
code = e.status || 1;
}
expect(code).toBe(1);
expect(search()).toContain("redact-target"); // nothing happened at all
});
});
describe("gstack-decision-search", () => {
+13 -3
View File
@@ -11,7 +11,7 @@
* Free-tier (~50ms total). Runs in `bun test`.
*/
import { describe, it, expect, beforeEach, afterAll } from "bun:test";
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, chmodSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
@@ -338,7 +338,11 @@ describe("withErrorContext", () => {
process.env.GSTACK_HOME = testHome;
});
afterAll(() => {
// afterEach, not afterAll: the save happens in beforeEach, so an afterAll
// restore would put back the PREVIOUS test's temp dir (the last beforeEach
// overwrote savedHome) and leak a gstack-test-home-* dir into every test
// file that runs after this one in the same bun process.
afterEach(() => {
if (savedHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = savedHome;
});
@@ -414,7 +418,13 @@ describe("detectEngineTier", () => {
process.env.HOME = testHome;
});
afterAll(() => {
// afterEach, not afterAll: the save happens in beforeEach, so an afterAll
// restore would put back the PREVIOUS test's gstack-test-engine-* temp dir
// (the last beforeEach overwrote the saved values). That leaked
// HOME/GSTACK_HOME/PATH into every test file that ran after this one in the
// same bun process — child processes then looked for Playwright's Chromium
// cache and ~/.gstack config under a throwaway temp HOME.
afterEach(() => {
if (savedHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = savedHome;
if (savedGbrainHome === undefined) delete process.env.GBRAIN_HOME;
+99
View File
@@ -4,6 +4,7 @@
// when the relevant CLI isn't available).
import { test, expect, describe } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -221,6 +222,104 @@ describe("resolveVersionPath (monorepo VERSION-path support)", () => {
});
});
// Fixture-repo coverage for the default-base detection chain in parseArgs:
// origin/HEAD symbolic-ref → origin/main probe → origin/master probe →
// literal "main". No --base and no --current-version are passed, so the
// detected base is observable through base_version: each fixture branch
// carries a distinct VERSION and readBaseVersion does
// `git show origin/<detected-base>:VERSION`.
describe("default-base detection (no --base)", () => {
const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-next-version");
// Point git at a nonexistent global/system config so operator settings
// (init.defaultBranch, commit.gpgsign, hooks, ...) can't leak into fixtures.
const noCfg = join(mkdtempSync(join(tmpdir(), "nextver-gitcfg-")), "empty");
const GIT_ENV = {
...process.env,
GIT_CONFIG_GLOBAL: noCfg,
GIT_CONFIG_SYSTEM: noCfg,
GIT_AUTHOR_NAME: "fixture",
GIT_AUTHOR_EMAIL: "fixture@example.com",
GIT_COMMITTER_NAME: "fixture",
GIT_COMMITTER_EMAIL: "fixture@example.com",
};
function git(cwd: string, ...args: string[]): void {
execFileSync("git", args, { cwd, env: GIT_ENV, stdio: ["ignore", "pipe", "pipe"] });
}
// Origin repo whose branches each hold a distinct VERSION, plus a clone
// (the clone is the repo the CLI runs in).
function makeClone(branches: Array<[name: string, version: string]>): { root: string; clone: string } {
const root = mkdtempSync(join(tmpdir(), "nextver-base-"));
const origin = join(root, "origin");
mkdirSync(origin);
git(origin, "init", "-q", "-b", branches[0][0]);
for (let i = 0; i < branches.length; i++) {
const [name, version] = branches[i];
if (i > 0) git(origin, "checkout", "-q", "-b", name);
writeFileSync(join(origin, "VERSION"), `${version}\n`);
git(origin, "add", "VERSION");
git(origin, "commit", "-q", "--no-gpg-sign", "-m", `VERSION ${version}`);
}
git(root, "clone", "-q", "origin", "clone");
return { root, clone: join(root, "clone") };
}
function runWithoutBase(cwd: string): { exitCode: number; parsed: any } {
const proc = Bun.spawnSync(
["bun", "run", SCRIPT, "--bump", "patch", "--workspace-root", "null"],
{ cwd },
);
const out = new TextDecoder().decode(proc.stdout);
return { exitCode: proc.exitCode, parsed: JSON.parse(out) };
}
test("origin/HEAD symbolic-ref wins — resolves trunk even though origin/main exists", () => {
const { root, clone } = makeClone([["main", "1.1.1.1"], ["trunk", "2.2.2.2"]]);
try {
git(clone, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk");
const { exitCode, parsed } = runWithoutBase(clone);
expect(exitCode).toBe(0);
// trunk's VERSION, not main's — the rev-parse probes never ran.
expect(parsed.base_version).toBe("2.2.2.2");
expect(parsed.version).toBe("2.2.3.0");
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 30_000);
test("no origin/HEAD, no origin/main: the origin/master probe resolves master", () => {
const { root, clone } = makeClone([["master", "3.3.3.3"]]);
try {
// Plain clones that never ran `git remote set-head` have no origin/HEAD.
git(clone, "remote", "set-head", "origin", "--delete");
const { exitCode, parsed } = runWithoutBase(clone);
expect(exitCode).toBe(0);
// Read at origin/master. The "main" literal fallback would have warned
// and assumed 0.0.0.0 instead.
expect(parsed.base_version).toBe("3.3.3.3");
expect(parsed.version).toBe("3.3.4.0");
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 30_000);
test("neither origin/HEAD nor main/master: falls back to the 'main' literal", () => {
const { root, clone } = makeClone([["develop", "4.4.4.4"]]);
try {
git(clone, "remote", "set-head", "origin", "--delete");
const { exitCode, parsed } = runWithoutBase(clone);
expect(exitCode).toBe(0);
// base = literal "main"; origin/main doesn't exist, so readBaseVersion
// warns and assumes 0.0.0.0 — which pins WHICH base the fallback chose.
expect(parsed.base_version).toBe("0.0.0.0");
expect(parsed.warnings.join("\n")).toContain("origin/main");
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 30_000);
});
// Integration smoke — only runs if gh is available and authenticated. Confirms
// the CLI executes end-to-end against real APIs without crashing.
describe("integration (smoke)", () => {
+4 -3
View File
@@ -11,6 +11,7 @@
* zero rendering loss. The TTY rendering layer is identical for fat and slim
* skills, so it is not where token-reduction degradation can hide.
*/
import { resolveEvalModel } from '../../lib/eval-model';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -191,7 +192,7 @@ This is a capture test, not an interactive session. Skip any system-audit / envi
timeout: 240_000,
testName: opts.testName,
runId: opts.runId,
model: opts.model ?? 'claude-sonnet-4-6', // D1a: align with session-runner default; Opus is opt-in
model: resolveEvalModel('capture', opts.model),
});
try {
@@ -253,7 +254,7 @@ Rules for this run:
timeout: opts.timeout ?? 300_000,
testName: opts.testName,
runId: opts.runId,
model: opts.model ?? 'claude-sonnet-4-6', // D1a: align with session-runner default; Opus is opt-in
model: resolveEvalModel('capture', opts.model),
});
const readSections = new Set<string>();
@@ -334,7 +335,7 @@ Write the verbatim text of that AskUserQuestion (the full decision brief: title,
timeout: 240_000,
testName: opts.testName,
runId: opts.runId,
model: opts.model ?? 'claude-sonnet-4-6', // D1a: align with session-runner default; Opus is opt-in
model: resolveEvalModel('capture', opts.model),
});
try {
+43 -22
View File
@@ -99,6 +99,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
ship: {
skill: 'ship',
expectedSections: [
'apple-release.md',
'tests.md',
'test-coverage.md',
'plan-completion.md',
@@ -129,7 +130,16 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
minUnionBytes: 120_000,
mustContain: ['VERSION', 'CHANGELOG', 'review', 'merge', 'PR'],
// v1.58.5.0: pre-push-guard install (#2077) stacks on the shared first-run-guidance preamble.
maxSizeRatio: 1.08,
// Fork port wave 2: multi-ecosystem test-detection evidence (Django/JVM
// markers, test-file census — e3259078 port) + the #1079 gh pr edit REST
// fallback grew the union to 1.090x; the third-party web-actions
// contract (consent-gated browser drive for API-key registration etc.)
// adds ~2.3KB inline judgment, measured 1.103x. The Apple release
// adapter (14.8KB carved section, 21 live releases of judgment — the
// wave's headline capability) grows the union to 1.195x. Deliberate:
// the section is on-demand (loads only for Apple store targets), so
// per-invocation cost for non-iOS ships is one manifest line.
maxSizeRatio: 1.22,
},
'plan-ceo-review': {
skill: 'plan-ceo-review',
@@ -144,9 +154,10 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
},
behavioral: 'external',
externalTest: 'test/skill-e2e-plan-ceo-review-section-loading.test.ts',
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
// the skeleton at 90,280 B; +1 KB headroom.
maxSkeletonBytes: 91_000,
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
// Fork port wave 2 (#703): the repo-doc-preference block in the design
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
maxSkeletonBytes: 92_500, // v1.64+v1.65 merge: both waves' preamble growth; measured 92,004
minUnionBytes: 80_000,
mustContain: ['SCOPE EXPANSION', 'SELECTIVE EXPANSION', 'HOLD SCOPE', 'SCOPE REDUCTION'],
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
@@ -167,9 +178,10 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
behavioral: 'plan',
// v1.2.0 activation lift (shared first-run-guidance preamble) + #2077 ask-first scope gate.
// +~1 KB: plan-mode auto-select-B scope-gate exceptions (2026-08).
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
// the skeleton at 68,163 B; +~1 KB headroom.
maxSkeletonBytes: 69_000,
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
// Fork port wave 2 (#703): the repo-doc-preference block in the design
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
maxSkeletonBytes: 70_000, // measured 68,780
minUnionBytes: 70_000,
mustContain: ['Architecture', 'Code Quality', 'Test', 'Performance'],
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback + the
@@ -180,7 +192,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// 1.08 → 1.10: the scope-gate exceptions block (+ its adversarial-review
// hardening: host-anchored mode signal, precedence, passing-mention
// guards) and the plan-mode preamble reword land the union at 1.092.
maxSizeRatio: 1.10,
maxSizeRatio: 1.12, // measured 1.103
},
'plan-design-review': {
skill: 'plan-design-review',
@@ -198,13 +210,14 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift (shared first-run-guidance preamble) + #2077 ask-first scope gate.
// +~1.3 KB: plan-mode auto-select-B scope-gate exceptions (2026-08).
// +~340 B: telemetry --error-message/--failed-step flags + prose in the
// shared completion-status preamble (PR #769, 2026-08); this skill was the
// closest to its ceiling (landed 89040 / ratio 1.072).
maxSkeletonBytes: 89_400,
// Fork port wave 2 (D1): evidence directive adds ~0.45KB to every
// tier-2+ skeleton (measured 89,184). Main's v1.64.0.0 adds ~340 B more
// (telemetry --error-message/--failed-step preamble prose, PR #769).
// Budget covers the sum of both waves.
maxSkeletonBytes: 91_000,
minUnionBytes: 70_000,
mustContain: ['design', 'visual'],
maxSizeRatio: 1.08,
maxSizeRatio: 1.12, // D1 1.104 + main's ~0.008
},
'plan-devex-review': {
skill: 'plan-devex-review',
@@ -221,7 +234,9 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// +Conductor AUQ-default-prose rule + one-way/destructive prose safety +
// continuation protocol in the always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
maxSkeletonBytes: 80_000,
// Fork port wave 2 (#703): the repo-doc-preference block in the design
// check grew every plan-review skeleton ~0.7KB. Measured values noted.
maxSkeletonBytes: 82_000, // measured 80,493
minUnionBytes: 70_000,
mustContain: ['developer experience', 'Getting Started'],
// Default-on Codex outside-voice (codexPreflight block + CODEX_MODE branch
@@ -244,12 +259,15 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
behavioral: 'prompt',
// v1.2.0 activation lift: first-run-guidance section in the shared preamble,
// plus the P1 office-hours closing handoff (AUQ that launches the next skill).
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
// the skeleton at 98,193 B; +~1 KB headroom.
maxSkeletonBytes: 99_000,
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
// Fork port wave 2: the third-party web-actions contract sits inline
// (judgment must be visible before the workflow directs the user to a
// vendor site), plus the #703 dual-write + repo-doc-preference block and
// the #538 opt-out + D1 evidence directive — ratio 1.104 measured.
maxSkeletonBytes: 101_000,
minUnionBytes: 70_000,
mustContain: ['design doc', 'problem statement'],
maxSizeRatio: 1.07,
maxSizeRatio: 1.12,
},
'document-release': {
skill: 'document-release',
@@ -267,7 +285,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
// always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
maxSkeletonBytes: 56_000,
maxSkeletonBytes: 56_500, // v1.64+v1.65 merge; measured 56,044
minUnionBytes: 55_000,
mustContain: ['CHANGELOG', 'Diataxis', 'coverage'],
// Two intentional additions stack on this small skill: the AUQ-failure prose
@@ -295,6 +313,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
// always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
// v1.65 merge: provisional larger-of-both-waves budget; re-measured below.
// v1.64.1.0: shared-preamble prose from the two parallel v1.64 waves lands
// the skeleton at 69,022 B; +~1 KB headroom.
maxSkeletonBytes: 70_000,
@@ -303,7 +322,8 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// Cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB +
// the cross-session decision-memory nudge) lands this carved skeleton just over
// the strict 1.05; headroom for the shared preamble additions.
maxSizeRatio: 1.07,
// v1.64+v1.65 merge sums both waves' preamble growth; measured 1.073.
maxSizeRatio: 1.08,
},
cso: {
skill: 'cso',
@@ -336,13 +356,14 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// +Conductor AUQ-default-prose rule + one-way/continuation safety in the
// always-loaded AskUserQuestion Format section.
// v1.2.0 activation lift: first-run-guidance section in the shared preamble.
maxSkeletonBytes: 75_000,
maxSkeletonBytes: 75_800, // v1.64+v1.65 merge; measured 75,364
minUnionBytes: 72_000,
mustContain: ['OWASP', 'STRIDE', 'daily', 'comprehensive', 'verif'],
// cso keeps its mode-dispatch + FP-filtering phases always-loaded, so the
// cross-cutting preamble growth (v1.57.2.0 AUQ-failure prose fallback ~2KB + the
// decision-memory nudge) lands it just over 1.05; headroom for the shared additions.
maxSizeRatio: 1.07,
// v1.64+v1.65 merge sums both waves' preamble growth; measured 1.073.
maxSizeRatio: 1.08,
},
};
+11 -2
View File
@@ -21,6 +21,7 @@
* tests don't need it).
*/
import { resolveEvalModel } from '../../lib/eval-model';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -459,7 +460,7 @@ ${tail}
// below resolveClaudeBinary(), breaking under hermetic PATHs.
const result = nodeSpawnSync(
resolveClaudeBinary() ?? 'claude',
['-p', '--model', 'claude-haiku-4-5', '--max-turns', '1'],
['-p', '--model', resolveEvalModel('warmup'), '--max-turns', '1'],
{
input: prompt,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -1256,7 +1257,15 @@ export const ceoStep0Boundary: Step0BoundaryPredicate = (fp) =>
export const engStep0Boundary: Step0BoundaryPredicate = (fp) =>
/scope reduction recommendation|cross[\s-]?project learnings/i.test(
fp.promptSnippet,
);
) ||
// plan-eng-review's Step 0 may legitimately end with NO scope-reduction /
// learnings AUQ. When it does, the first answered review-phase question —
// tagged <gstack-qid:plan-eng-review-...> ({skill}-{slug} convention) —
// must fire the boundary, or every per-finding AUQ stays classified
// preReview and the multi-finding batching counter reads 0. Anchor allows
// the skill-name prefix; live qids observed: plan-eng-review-jitter,
// plan-eng-review-idempotency, plan-eng-review-todos-e2e-concurrent.
/gstack-qid:\s*(?:plan-)?eng-review-/i.test(fp.promptSnippet);
export const designStep0Boundary: Step0BoundaryPredicate = (fp) =>
/design system|design posture|design score|first dimension/i.test(
+14 -4
View File
@@ -214,7 +214,9 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
// codexPreflight() block (install + auth tri-state + CODEX_MODE branch prose),
// landing ~6.3% over the v1.53.0.0 baseline. Intentional: it adds proper
// not-installed vs not-authed handling, not slop.
maxSizeRatio: 1.08,
// v1.64+v1.65 merge: both waves grew the shared preamble (evidence
// directive + telemetry failure flags); measured 1.094.
maxSizeRatio: 1.10,
minBytes: 70_000,
},
{
@@ -223,7 +225,11 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
mustHaveHeadings: ['## Preamble', '## When to invoke'],
// v1.2.0 activation lift: the unified first-run-guidance section (P4 scaffold +
// P3 loop tip) is added to every skill's shared preamble — intentional, ~1KB.
maxSizeRatio: 1.07,
// Fork port wave 2: the shared coverage-audit detection block gained the
// multi-ecosystem markers (Django/JVM, script/target/test-file census —
// e3259078 port); measured 1.111x. v1.64+v1.65 merge sums both waves'
// preamble growth; measured 1.125.
maxSizeRatio: 1.13,
minBytes: 50_000,
},
{
@@ -237,7 +243,9 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
// 1.09 → 1.10: the plan-mode preamble reword (scope-gate auto-select-B
// change) adds ~250 B to every skill's shared preamble; investigate was
// the closest to its ceiling (landed 1.092).
maxSizeRatio: 1.10,
// Fork port wave 2 (D1): the evidence-before-claimed-limitations preamble
// directive adds ~0.45KB to every tier-2+ skill. Measured values noted.
maxSizeRatio: 1.12, // D1 measured
minBytes: 30_000,
},
{
@@ -245,7 +253,9 @@ const MONOLITH_INVARIANTS: ParityInvariant[] = [
mustContain: ['ceo', 'eng', 'design'],
mustHaveHeadings: ['## Preamble', '## When to invoke'],
// v1.2.0 activation lift: shared first-run-guidance preamble section.
maxSizeRatio: 1.07,
// Fork port wave 2 (D1): the evidence-before-claimed-limitations preamble
// directive adds ~0.45KB to every tier-2+ skill. Measured values noted.
maxSizeRatio: 1.09, // D1 measured
minBytes: 70_000,
},
];
+22 -3
View File
@@ -1,6 +1,6 @@
import type { ProviderAdapter, RunOpts, RunResult, AvailabilityCheck } from './types';
import { estimateCostUsd } from '../pricing';
import { execFileSync } from 'child_process';
import { execFileSync, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@@ -25,11 +25,30 @@ export class ClaudeAdapter implements ProviderAdapter {
if (!resolved) {
return { ok: false, reason: 'claude CLI not found on PATH. Install from https://claude.ai/download or npm i -g @anthropic-ai/claude-code (or set GSTACK_CLAUDE_BIN)' };
}
// Auth sniff: ~/.claude/.credentials.json OR ANTHROPIC_API_KEY
// Auth sniff: ~/.claude/.credentials.json OR ANTHROPIC_API_KEY OR (macOS)
// the Keychain entry subscription installs use instead of the creds file.
// #1890: the default macOS install stores OAuth under the generic-password
// service "Claude Code-credentials" and never writes .credentials.json,
// so the file-or-env sniff reported "No Claude auth found" while
// `claude -p` worked fine. Metadata probe only (no -w — never reads the
// secret), and any failure of `security` itself falls through to the
// not-found reason rather than throwing.
const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
const hasCreds = fs.existsSync(credsPath);
const hasKey = !!process.env.ANTHROPIC_API_KEY;
if (!hasCreds && !hasKey) {
let hasKeychain = false;
if (!hasCreds && !hasKey && process.platform === 'darwin') {
try {
const probe = spawnSync('security', ['find-generic-password', '-s', 'Claude Code-credentials'], {
stdio: 'ignore',
timeout: 5000,
});
hasKeychain = probe.status === 0;
} catch {
hasKeychain = false;
}
}
if (!hasCreds && !hasKey && !hasKeychain) {
return { ok: false, reason: 'No Claude auth found. Log in via `claude` interactive session, or export ANTHROPIC_API_KEY.' };
}
return { ok: true };
+48
View File
@@ -0,0 +1,48 @@
/**
* StateServer hardening pins (fork port wave 2, B3 + B5).
*
* B3: the boot token must never appear in an os_log statement. The daemon
* reads it from the 0600 app-container file (copyFileFromAppContainer in
* tunnel-bootstrap.ts); the old `token=\(self.bootToken, privacy: .public)`
* announce line had NO consumer and handed a live credential to anything
* reading the unified log during the launch window.
*
* B5: the IPv4 listener has no CoreDevice tunnel path, so it must bind to
* loopback at the socket level (requiredLocalEndpoint 127.0.0.1), not rely
* solely on the per-connection peer check. IPv6 keeps the wildcard bind for
* CoreDevice ULA peers by design.
*
* Pinned on BOTH the generated template and the fixture app copy so neither
* can drift back independently.
*/
import { describe, test, expect } from "bun:test";
import { readFileSync } from "fs";
import { join } from "path";
const ROOT = join(import.meta.dir, "..");
const COPIES = [
"ios-qa/templates/StateServer.swift.template",
"test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift",
];
describe.each(COPIES)("StateServer hardening — %s", (rel) => {
const src = readFileSync(join(ROOT, rel), "utf-8");
test("no os_log statement interpolates the boot token (B3)", () => {
const logLines = src.split("\n").filter((l) => /logger\.(notice|info|error|debug|log)/.test(l));
for (const line of logLines) {
expect(line).not.toContain("bootToken");
}
// The bootstrap announce survives (diagnostics), token-free.
expect(src).toContain('gstack-ios-qa-bootstrap port=');
expect(src).not.toContain("gstack-ios-qa-bootstrap token=");
});
test("IPv4 listener binds loopback at the socket level (B5)", () => {
expect(src).toContain("requiredLocalEndpoint");
expect(src).toContain('NWEndpoint.Host("127.0.0.1")');
// IPv6 wildcard + peer-check path must survive (CoreDevice tunnel peers).
expect(src).toMatch(/case \.ipv6:\s*\n\s*listener = try NWListener\(using: params, on:/);
});
});
+61 -2
View File
@@ -22,7 +22,7 @@
import { describe, it, expect } from "bun:test";
import { execFileSync } from "child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, symlinkSync, realpathSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { readFileSync } from "fs";
@@ -45,11 +45,70 @@ describe("gstack-memory-ingest: gbrain import must not be filtered by .gitignore
const stripped = stripComments(readFileSync(SOURCE_PATH, "utf-8"));
// Match the spawn call's argument array and assert both the subcommand
// and the flag live in it, so the flag can't drift onto another call.
const call = stripped.match(/spawnGbrainAsync\(\s*\[[^\]]*"import"[^\]]*\]/s);
// [\s\S]*? bridges the conditional-spread's nested brackets (main's
// capability-probed --include-gitignored merged with our baseEnv defense).
const call = stripped.match(/spawnGbrainAsync\(\s*\[[\s\S]*?"import"[\s\S]*?\]/s);
expect(call).not.toBeNull();
expect(call![0]).toContain("--include-gitignored");
});
it("sets a realpath'd GIT_CEILING_DIRECTORIES on the import child (defense-in-depth)", () => {
const stripped = stripComments(readFileSync(SOURCE_PATH, "utf-8"));
// Second #2144 layer: the ceiling env must be built from the staging
// dir's REAL parent path and merged into the spawn's baseEnv, so a
// git-enumerating collector fails out of the git fast path even when
// the flag's semantics drift, and symlinked staging paths still match.
expect(stripped).toContain("GIT_CEILING_DIRECTORIES");
expect(stripped).toMatch(/realpathSync\(dirname\(stagingDir\)\)/);
const call = stripped.match(/spawnGbrainAsync\(\s*\[[\s\S]*?"import"[\s\S]*?\]\s*,\s*\{\s*baseEnv\s*\}/s);
expect(call).not.toBeNull();
});
it("proves the ceiling stops git discovery from the staging dir — including through a symlink", () => {
const dir = mkdtempSync(join(tmpdir(), "gstack-ingest-ceiling-"));
try {
const git = (args: string[], cwd: string, env?: NodeJS.ProcessEnv) =>
execFileSync("git", args, {
cwd,
encoding: "utf-8",
env: { ...process.env, ...env },
});
// ~/.gstack shape: a git repo whose root ignores everything, with the
// staging dir as a direct child.
const home = join(dir, "gstack-home");
mkdirSync(home, { recursive: true });
git(["init", "-q", "."], home);
writeFileSync(join(home, ".gitignore"), "*\n", "utf-8");
const staging = join(home, ".staging-ingest-12345-1700000000000");
mkdirSync(staging, { recursive: true });
// Without a ceiling: discovery from the staging dir finds the repo —
// this is the git fast path that collects zero files.
const found = git(["rev-parse", "--show-toplevel"], staging).trim();
expect(realpathSync(found)).toBe(realpathSync(home));
// With the ceiling at the staging dir's REAL parent: discovery fails,
// which is exactly what pushes a collector onto its plain FS walk.
const ceiling = realpathSync(home);
expect(() =>
git(["rev-parse", "--show-toplevel"], staging, { GIT_CEILING_DIRECTORIES: ceiling }),
).toThrow();
// Symlink variant (the OV4 trap): reach the same staging dir through a
// symlinked path. A realpath'd ceiling still stops discovery.
const linked = join(dir, "linked-home");
symlinkSync(home, linked);
const stagingViaLink = join(linked, ".staging-ingest-12345-1700000000000");
expect(() =>
git(["rev-parse", "--show-toplevel"], stagingViaLink, {
GIT_CEILING_DIRECTORIES: ceiling,
}),
).toThrow();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("demonstrates the collision: an ignore-everything root hides staged pages", () => {
const dir = mkdtempSync(join(tmpdir(), "gstack-ingest-gitignore-"));
try {
+66 -1
View File
@@ -71,7 +71,11 @@ function run(extraEnv: Record<string, string> = {}, input = ''): { code: number;
PATH: `${fakeBinDir}:${path.join(ROOT, 'bin')}:/usr/bin:/bin:/opt/homebrew/bin`,
HOME: tmpHome,
USER: 'testuser',
// Disable interactive prompt: empty stdin = treat as non-interactive.
// Empty stdin = non-interactive. Since #1383 the script SKIPS by
// default in that context (a remote repo rename is consent-shaped);
// the harness opts in explicitly, simulating a consenting unattended
// run. The default-skip contract has its own test below.
GSTACK_MIGRATE_ASSUME_YES: '1',
...extraEnv,
},
encoding: 'utf-8',
@@ -168,6 +172,67 @@ describe('v1.27.0.0 migration — GitHub host (non-interactive)', () => {
});
});
describe('v1.27.0.0 migration — #1383 consent + failure-stays-pending contract', () => {
beforeEach(() => {
fs.writeFileSync(
path.join(tmpHome, '.gstack-brain-remote.txt'),
'https://github.com/testuser/gstack-brain-testuser\n'
);
fs.writeFileSync(
path.join(tmpHome, '.gstack/config.yaml'),
'gbrain_sync_mode: full\n'
);
makeFakeGh({});
});
test('non-interactive without opt-in: skips for now, touches NOTHING', () => {
const r = run({ GSTACK_MIGRATE_ASSUME_YES: '0' });
expect(r.code).toBe(0);
expect(r.stderr).toContain('skipping for now');
expect(r.stderr).toContain('GSTACK_MIGRATE_ASSUME_YES=1');
// The remediation must be REAL: a direct invocation of this script.
// `/setup-gbrain --rerun-migration` never existed, and the runners'
// version windows never re-select a passed migration, so "will ask
// again next upgrade" was false.
expect(r.stderr).toContain('v1.27.0.0.sh');
expect(r.stderr).not.toContain('--rerun-migration');
expect(r.stderr).not.toContain('ask again');
// Old state untouched, nothing recorded as done.
expect(fs.existsSync(path.join(tmpHome, '.gstack-brain-remote.txt'))).toBe(true);
expect(fs.existsSync(path.join(tmpHome, '.gstack-artifacts-remote.txt'))).toBe(false);
expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(false);
});
test('gh rename failure: step stays pending, migration exits INCOMPLETE, retry succeeds', () => {
makeFakeGh({ renameSucceeds: false });
const r = run();
// Failure must be loud and the migration visibly incomplete — the old
// behavior marked the failed step done and wrote the done touchfile,
// permanently stranding a half-renamed install (#1383).
expect(r.code).toBe(1);
expect(r.stderr).toContain('PENDING');
expect(r.stderr).toContain('INCOMPLETE');
// Honest remediation: direct invocation, not the nonexistent
// /setup-gbrain --rerun-migration flag.
expect(r.stderr).toContain('Re-run manually with:');
expect(r.stderr).toContain('GSTACK_MIGRATE_ASSUME_YES=1');
expect(r.stderr).not.toContain('--rerun-migration');
expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(false);
const journal = fs.readFileSync(
path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.journal'),
'utf-8'
);
expect(journal).not.toContain('gh_repo_renamed');
// Later steps DID run and are journaled (independent of step 1)...
expect(journal).toContain('remote_txt_renamed');
// ...so a retry with working gh only redoes step 1 and completes.
makeFakeGh({});
const r2 = run();
expect(r2.code).toBe(0);
expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(true);
});
});
describe('v1.27.0.0 migration — interruption resume', () => {
beforeEach(() => {
fs.writeFileSync(
+240
View File
@@ -0,0 +1,240 @@
/**
* v1.65.0.0 migration remove rebrand-poisoned Chromium bundles (#2242)
* and re-fetch a clean one, gating .done on a VERIFIED end state.
*
* Exercises the migration in a hermetic temp HOME + temp Playwright cache
* (HOME / GSTACK_HOME / PLAYWRIGHT_BROWSERS_PATH all redirected) with a
* stubbed bunx on PATH nothing touches the real cache. Covers:
* - whole-revision-dir removal (INSTALLATION_COMPLETE marker included, so
* `playwright install` can't no-op with "is already downloaded")
* - clean bundles untouched
* - re-fetch runs from the gstack install root (repo-pinned playwright)
* - .done only written once a Chromium executable verifiably exists
* - failed re-fetch warning + retry on next run (needs-refetch sentinel)
* - stranded revision dir (markers without .app) re-triggers the re-fetch
* - idempotent re-run after success
* - non-Darwin early exit
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const MIGRATION = path.join(ROOT, 'gstack-upgrade', 'migrations', 'v1.65.0.0.sh');
let tmpHome: string;
let fakeBinDir: string;
let pwCache: string;
const POISONED_PLIST =
'<plist><dict><key>CFBundleName</key><string>GStack Browser</string></dict></plist>';
const CLEAN_PLIST =
'<plist><dict><key>CFBundleName</key><string>Google Chrome for Testing</string></dict></plist>';
/** Build a Playwright-cache-shaped bundle: <revDir>/chrome-mac/<name>.app */
function makeBundle(
revDir: string,
opts: { plist: string; withExecutable?: boolean; withMarkers?: boolean }
): string {
const appDir = path.join(revDir, 'chrome-mac', 'Google Chrome for Testing.app');
const contents = path.join(appDir, 'Contents');
fs.mkdirSync(contents, { recursive: true });
fs.writeFileSync(path.join(contents, 'Info.plist'), opts.plist);
if (opts.withExecutable) {
const macos = path.join(contents, 'MacOS');
fs.mkdirSync(macos, { recursive: true });
fs.writeFileSync(path.join(macos, 'Google Chrome for Testing'), '#!/bin/sh\n', {
mode: 0o755,
});
}
if (opts.withMarkers ?? true) {
fs.writeFileSync(path.join(revDir, 'INSTALLATION_COMPLETE'), '');
fs.writeFileSync(path.join(revDir, 'DEPENDENCIES_VALIDATED'), '');
}
return appDir;
}
/**
* Stub bunx: records every invocation (args + cwd) and, unless told
* otherwise, simulates a successful `playwright install chromium` by
* creating a fresh revision dir with an executable in the temp cache.
*/
function makeFakeBunx(opts: { createsExecutable?: boolean } = {}): string {
const creates = opts.createsExecutable ?? true;
const callLog = path.join(fakeBinDir, 'bunx-calls.log');
const script = `#!/bin/bash
echo "bunx $@ (pwd=$(pwd))" >> "${callLog}"
${
creates
? `FRESH="\${PLAYWRIGHT_BROWSERS_PATH}/chromium-9999/chrome-mac/Chromium.app/Contents/MacOS"
mkdir -p "\${FRESH}"
printf '#!/bin/sh\\n' > "\${FRESH}/Chromium"
chmod 755 "\${FRESH}/Chromium"
touch "\${PLAYWRIGHT_BROWSERS_PATH}/chromium-9999/INSTALLATION_COMPLETE"`
: '# simulates an offline / failed download: no files created'
}
exit 0
`;
fs.writeFileSync(path.join(fakeBinDir, 'bunx'), script, { mode: 0o755 });
return callLog;
}
function run(extraEnv: Record<string, string> = {}): {
code: number;
stdout: string;
stderr: string;
} {
const r = spawnSync(MIGRATION, [], {
env: {
PATH: `${fakeBinDir}:/usr/bin:/bin`,
HOME: tmpHome,
GSTACK_HOME: path.join(tmpHome, '.gstack'),
PLAYWRIGHT_BROWSERS_PATH: pwCache,
...extraEnv,
},
encoding: 'utf-8',
cwd: tmpHome,
});
return { code: r.status ?? -1, stdout: r.stdout || '', stderr: r.stderr || '' };
}
const doneFile = () => path.join(tmpHome, '.gstack', '.migrations', 'v1.65.0.0.done');
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-v1.65-'));
fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mig-v1.65-fake-'));
pwCache = path.join(tmpHome, 'pw-cache', 'ms-playwright');
fs.mkdirSync(pwCache, { recursive: true });
fs.mkdirSync(path.join(tmpHome, '.gstack'), { recursive: true });
// The migration gates on `uname -s` = Darwin; on the Linux CI runner the
// real uname made every test early-exit with empty output. Shim Darwin so
// the Darwin-path tests run everywhere; the non-Darwin test overwrites
// this shim with its own Linux uname.
fs.writeFileSync(path.join(fakeBinDir, 'uname'), '#!/bin/bash\necho Darwin\n', {
mode: 0o755,
});
});
afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(fakeBinDir, { recursive: true, force: true });
});
describe('v1.65.0.0 migration — poisoned bundle removal', () => {
test('poisoned revision dir removed WHOLE (markers included); clean bundle untouched; verified re-fetch → .done', () => {
const poisonedRev = path.join(pwCache, 'chromium-1234');
makeBundle(poisonedRev, { plist: POISONED_PLIST });
const cleanRev = path.join(pwCache, 'chromium-5678');
const cleanApp = makeBundle(cleanRev, { plist: CLEAN_PLIST, withExecutable: true });
const log = makeFakeBunx();
const r = run();
expect(r.code).toBe(0);
// The WHOLE revision dir is gone — .app AND the INSTALLATION_COMPLETE /
// DEPENDENCIES_VALIDATED markers. Removing only the .app leaves markers
// that make `playwright install chromium` no-op ("is already
// downloaded"), stranding the user with NO browser + a success message.
expect(fs.existsSync(poisonedRev)).toBe(false);
// Clean bundle untouched.
expect(fs.existsSync(path.join(cleanApp, 'Contents', 'Info.plist'))).toBe(true);
expect(fs.existsSync(path.join(cleanRev, 'INSTALLATION_COMPLETE'))).toBe(true);
// Re-fetch invoked...
const calls = fs.readFileSync(log, 'utf-8');
expect(calls).toContain('playwright install chromium');
// ...from the gstack install root (repo-pinned playwright version), not
// from the arbitrary cwd the migration runner happened to use.
const realRoot = fs.realpathSync(ROOT);
expect(calls.includes(`pwd=${ROOT}`) || calls.includes(`pwd=${realRoot}`)).toBe(true);
// Verified end state → .done written.
expect(fs.existsSync(doneFile())).toBe(true);
});
test('re-fetch produces no executable → WARNING, .done NOT written; next run retries and completes', () => {
makeBundle(path.join(pwCache, 'chromium-1234'), { plist: POISONED_PLIST });
const log = makeFakeBunx({ createsExecutable: false });
const r = run();
expect(r.code).toBe(0); // non-fatal per the migration contract
expect(r.stderr).toContain('WARNING');
expect(r.stderr).toContain('bunx playwright install chromium');
// Removal succeeded but the end state is "no browser": .done must NOT
// be written so a re-run retries instead of recording success.
expect(fs.existsSync(doneFile())).toBe(false);
// Re-run with a working fetch: the pending state re-triggers the
// re-fetch even though the scans have nothing left to remove.
fs.rmSync(log, { force: true });
makeFakeBunx({ createsExecutable: true });
const r2 = run();
expect(r2.code).toBe(0);
expect(fs.readFileSync(log, 'utf-8')).toContain('playwright install chromium');
expect(fs.existsSync(doneFile())).toBe(true);
});
test('stranded revision dir (install markers, no .app) re-triggers removal + re-fetch', () => {
// The state an older version of this migration could leave behind:
// .app removed, INSTALLATION_COMPLETE still present → `playwright
// install` no-ops while the user has no browser at all.
const strandedRev = path.join(pwCache, 'chromium-1234');
fs.mkdirSync(path.join(strandedRev, 'chrome-mac'), { recursive: true });
fs.writeFileSync(path.join(strandedRev, 'INSTALLATION_COMPLETE'), '');
const log = makeFakeBunx();
const r = run();
expect(r.code).toBe(0);
expect(fs.existsSync(strandedRev)).toBe(false);
expect(fs.readFileSync(log, 'utf-8')).toContain('playwright install chromium');
expect(fs.existsSync(doneFile())).toBe(true);
});
test('clean cache → no-op, no bunx call, .done written', () => {
const cleanRev = path.join(pwCache, 'chromium-5678');
const cleanApp = makeBundle(cleanRev, { plist: CLEAN_PLIST, withExecutable: true });
const log = makeFakeBunx();
const r = run();
expect(r.code).toBe(0);
expect(r.stderr).toContain('no-op');
expect(fs.existsSync(log)).toBe(false); // bunx never invoked
expect(fs.existsSync(path.join(cleanApp, 'Contents', 'Info.plist'))).toBe(true);
expect(fs.existsSync(doneFile())).toBe(true);
});
test('second run after success → silent no-op (no rescan, no bunx)', () => {
makeBundle(path.join(pwCache, 'chromium-1234'), { plist: POISONED_PLIST });
const log = makeFakeBunx();
const r1 = run();
expect(r1.code).toBe(0);
expect(fs.existsSync(doneFile())).toBe(true);
fs.rmSync(log, { force: true });
const r2 = run();
expect(r2.code).toBe(0);
expect(r2.stderr).toBe('');
expect(fs.existsSync(log)).toBe(false);
});
test('non-Darwin → early exit, cache untouched, no bunx, .done written', () => {
const poisonedApp = makeBundle(path.join(pwCache, 'chromium-1234'), {
plist: POISONED_PLIST,
});
makeFakeBunx();
// The script gates on `uname -s` != Darwin; shadow uname on PATH.
fs.writeFileSync(path.join(fakeBinDir, 'uname'), '#!/bin/bash\necho Linux\n', {
mode: 0o755,
});
const r = run();
expect(r.code).toBe(0);
expect(fs.existsSync(poisonedApp)).toBe(true); // nothing removed
expect(fs.existsSync(path.join(fakeBinDir, 'bunx-calls.log'))).toBe(false);
expect(fs.existsSync(doneFile())).toBe(true);
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* Regression pin for #2091: /codex was broken on every macOS install because
* its mktemp templates carried a suffix after the X's ("codex-err-XXXXXX.txt").
* BSD mktemp (macOS) requires the X's to be the trailing characters of the
* template; with a suffix it fails ("mkstemp failed ... File exists"), the
* temp file never exists, and the skill dies before Codex ever runs.
*
* GNU mktemp accepts a --suffix flag but ALSO rejects inline suffixes in the
* template argument on BusyBox, so the portable form is: X's last, no suffix.
*
* This scans every .tmpl (the sources of truth generated SKILL.md files
* follow at regen time) for the broken shape.
*/
import { describe, it, expect } from "bun:test";
import { execFileSync } from "child_process";
import { readFileSync } from "fs";
import { join } from "path";
const ROOT = join(import.meta.dir, "..");
function trackedTmplFiles(): string[] {
const out = execFileSync("git", ["ls-files", "*.tmpl", "**/*.tmpl"], {
cwd: ROOT,
encoding: "utf-8",
});
return out.split("\n").filter(Boolean);
}
describe("mktemp portability (#2091)", () => {
it("no .tmpl file uses a mktemp template with characters after XXXXXX", () => {
const offenders: string[] = [];
for (const rel of trackedTmplFiles()) {
const src = readFileSync(join(ROOT, rel), "utf-8");
src.split("\n").forEach((line, i) => {
// Broken shape: the X-run followed by a non-quote, non-whitespace,
// non-closing character inside a mktemp invocation. X{6,} is greedy,
// so longer X-runs (spec's XXXXXXXX) stay valid — only a genuine
// suffix after the final X trips it.
if (/mktemp[^\n]*X{6,}[^"'\s)X]/.test(line)) {
offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
}
});
}
expect(offenders).toEqual([]);
});
it("BSD-portable form actually works on this platform", () => {
// Live sanity: the exact template shape the skills now emit.
const tmp = process.env.TMPDIR || "/tmp";
const created = execFileSync("mktemp", [`${tmp.replace(/\/$/, "")}/gstack-portability-XXXXXX`], {
encoding: "utf-8",
}).trim();
expect(created.length).toBeGreaterThan(0);
execFileSync("rm", ["-f", created]);
});
});
+6
View File
@@ -88,6 +88,12 @@ const SCAN_PATHS = [
'review/SKILL.md.tmpl',
'ship/SKILL.md.tmpl',
'test/',
// Fork port wave 2: docs were deliberately excluded and that exact gap let
// a dead command (gstack-brain-init) survive ~36 releases as a
// command-not-found instruction. Docs are user-facing surface; scan them.
'docs/',
'README.md',
'USING_GBRAIN_WITH_GSTACK.md',
];
function grepRefs(pattern: string): string[] {
+56
View File
@@ -0,0 +1,56 @@
/**
* R2 pin (fork port wave 2): the Apple release adapter loads BEFORE ship's
* branch gate, and the non-Apple gate is byte-unchanged.
*
* Two failure modes this prevents: a future ship-template refactor that
* re-blocks store releases behind "ship from a feature branch" (the exact
* live failure the fork hit a solo dev with a clean tree on main shipping
* to TestFlight got aborted over branch topology), and the reverse the
* Apple path accidentally weakening the branch gate for normal
* repository-landing ships.
*/
import { describe, test, expect } from "bun:test";
import { readFileSync } from "fs";
import { join } from "path";
const ROOT = join(import.meta.dir, "..");
const SKELETON = readFileSync(join(ROOT, "ship", "SKILL.md"), "utf-8");
const GATE_TEXT =
'If on the base branch or the repo\'s default branch, **abort**: "You\'re on the base branch. Ship from a feature branch."';
describe("ship Apple gate ordering (R2)", () => {
test("the Apple adapter read directive precedes the branch gate", () => {
const appleRead = SKELETON.indexOf("sections/apple-release.md");
const gate = SKELETON.indexOf(GATE_TEXT);
expect(appleRead).toBeGreaterThan(-1);
expect(gate).toBeGreaterThan(-1);
expect(appleRead).toBeLessThan(gate);
});
test("store distribution explicitly bypasses the branch/PR ceremony", () => {
expect(SKELETON).toContain("Store distribution proceeds");
expect(SKELETON).toMatch(/branch gate and repository-landing pipeline below apply ONLY to\s*\n?repository-landing asks/);
});
test("the non-Apple branch gate is byte-unchanged and appears exactly once", () => {
const first = SKELETON.indexOf(GATE_TEXT);
expect(first).toBeGreaterThan(-1);
expect(SKELETON.indexOf(GATE_TEXT, first + 1)).toBe(-1);
});
test("the adapter section exists in the union with its battle-tested spine", () => {
const section = readFileSync(join(ROOT, "ship", "sections", "apple-release.md"), "utf-8");
for (const anchor of [
"one authorization moment",
"fastlane spaceauth",
"iris/v1/apiKeys",
"appPriceSchedules",
"CLASSIFY the error before touching credentials",
"Never abort an App Store release over branch topology",
]) {
expect(section).toContain(anchor);
}
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* Regression: /ship Step 4 test-framework detection was blind to Django.
*
* A real Django project (manage.py + <app>/tests.py, green `python manage.py
* test`, no pytest.ini and no tests/ directory) read as "no test framework",
* so /ship bootstrapped pytest on top of a working suite. Same blindness hit
* any config-less-but-tested project (Go *_test.go, in-source Rust #[test],
* package.json with only a test script).
*
* These run the resolver's OWN emitted detection block against fixtures, so
* the shell is checked, not just the prose and the test is independent of
* generated-SKILL.md regen state.
*
* Ported from time-attack/gstack commit e3259078 (GStack 2), adapted from the
* fork's generated-markdown target to our resolver source of truth.
*/
import { describe, test, expect } from 'bun:test';
import { execFileSync } from 'node:child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { generateTestBootstrap } from '../scripts/resolvers/testing';
const section = generateTestBootstrap({} as never);
/** The detection block the skill tells the agent to run (first bash fence). */
function detectionScript(): string {
const open = section.indexOf('```bash\n');
expect(open).toBeGreaterThan(-1);
const start = open + '```bash\n'.length;
const end = section.indexOf('```', start);
return section.slice(start, end);
}
/** Run the detection block in a throwaway git repo laid out by `files`. */
function detect(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-detect-'));
try {
for (const [rel, body] of Object.entries(files)) {
const abs = path.join(dir, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
}
const script = path.join(dir, '.detect.sh');
fs.writeFileSync(script, detectionScript());
const git = (...args: string[]) => execFileSync('git', args, { cwd: dir });
git('init', '-q', '.');
git('add', '-A');
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture');
// The block's last line is a `[ -f marker ] && echo`, so a clean project
// exits 1 by design — read stdout, don't trust the status.
return execFileSync('bash', [script], { cwd: dir, encoding: 'utf-8' });
} catch (err: unknown) {
const e = err as { stdout?: string };
if (typeof e.stdout === 'string') return e.stdout;
throw err;
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
const testFileCount = (out: string): number =>
Number(/^TESTFILES:(\d+)$/m.exec(out)?.[1] ?? -1);
describe('/ship Step 4 detection is multi-ecosystem', () => {
test('Django project reports manage.py and its existing tests', () => {
const out = detect({
'manage.py': '#!/usr/bin/env python\n',
'requirements.txt': 'Django==5.0\n',
'polls/tests.py': 'from django.test import TestCase\n',
});
expect(out).toContain('MARKER:manage.py');
expect(out).toContain('FRAMEWORK:django');
expect(testFileCount(out)).toBeGreaterThan(0);
});
test('config-less projects with tests still report test evidence', () => {
expect(testFileCount(detect({
'go.mod': 'module x\n',
'x_test.go': 'package x\n',
}))).toBeGreaterThan(0);
const node = detect({
'package.json': '{"name":"n","scripts":{"test":"node --test"}}\n',
});
expect(node).toContain('SCRIPT:package.json test');
const rust = detect({
'Cargo.toml': '[package]\nname="x"\n',
'src/lib.rs': '#[cfg(test)]\nmod t{ #[test] fn x(){} }\n',
});
expect(rust).toContain('TESTS:rust in-source');
});
test('a genuinely untested project reports no test evidence', () => {
const out = detect({ 'go.mod': 'module x\n', 'x.go': 'package x\n' });
expect(out).toContain('RUNTIME:go');
expect(testFileCount(out)).toBe(0);
expect(out).not.toContain('TESTS:');
});
test('every ecosystem marker maps to a command to OFFER, not to run blind', () => {
expect(section).toContain('never a command to run blind');
for (const marker of ['manage.py', 'go.mod', 'Cargo.toml', 'pom.xml',
'build.gradle', 'mix.exs', 'composer.json', 'Gemfile', 'pytest.ini']) {
expect(section).toContain(marker);
}
// The ask-and-persist contract, not a hardcoded project command.
expect(section).toContain('AskUserQuestion');
expect(section).toContain('persist the answer to CLAUDE.md');
});
});
+2 -2
View File
@@ -111,7 +111,7 @@ describeE2E('AUTO_DECIDE opt-in preserved under Conductor flags (periodic)', ()
skillName: 'plan-ceo-review',
inPlanMode: true,
extraArgs: ['--disallowedTools', 'AskUserQuestion'],
timeoutMs: 300_000,
timeoutMs: 540_000,
env: { GSTACK_HOME: tmpHome, CONDUCTOR_WORKSPACE_PATH: tmpHome },
});
@@ -135,5 +135,5 @@ describeE2E('AUTO_DECIDE opt-in preserved under Conductor flags (periodic)', ()
} finally {
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}, 360_000);
}, 660_000);
});
+31 -26
View File
@@ -34,6 +34,12 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
// Stage a fresh GSTACK_HOME with artifacts_sync_mode_prompted=false.
const gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-gstack-'));
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-bin-'));
// Fresh HOME with NO ~/.claude.json: on a machine where gbrain is
// registered type=http, the preamble's remote-mode detection reads the
// operator's ~/.claude.json and echoes "ARTIFACTS_SYNC: remote-mode" —
// and the local privacy gate legitimately never fires. An empty HOME
// makes the detection find nothing.
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-home-'));
// Seed the config so the gate's condition passes.
fs.writeFileSync(
@@ -59,12 +65,16 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
const binary = resolveClaudeBinary();
// Ambient env mutations — restored in finally so other tests in the file
// don't inherit them.
const origGstackHome = process.env.GSTACK_HOME;
const origPath = process.env.PATH;
process.env.GSTACK_HOME = gstackHome;
process.env.PATH = `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
// Per-test env, merged LAST by the hermetic env builder (safe post-v1.39:
// the runner always passes a COMPLETE hermetic env, so overrides can't
// break auth). Ambient process.env.GSTACK_HOME mutation does NOT work
// here — hermetic-env scrubs GSTACK_* and repoints GSTACK_HOME at its
// own singleton dir, so the staged config would never reach the child.
const childEnv = {
GSTACK_HOME: gstackHome,
HOME: tempHome,
PATH: `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`,
};
try {
// Pick a small skill with the preamble and load it via Read to force
@@ -85,12 +95,7 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
workingDirectory: gstackHome,
maxTurns: 10,
allowedTools: ['Read', 'Grep', 'Glob', 'Bash'],
// NOTE: do NOT pass `env:` here. When the Agent SDK gets an explicit
// env object, its auth pipeline doesn't pick up ANTHROPIC_API_KEY the
// same way as when env is undefined (SDK-internal detail, verified
// against the plan-mode-no-op test which passes no env and auths
// cleanly). Instead, mutate process.env before the call so the SDK
// inherits our overrides ambiently.
env: childEnv,
...(binary ? { pathToClaudeCodeExecutable: binary } : {}),
canUseTool: async (toolName, input) => {
if (toolName === 'AskUserQuestion') {
@@ -141,13 +146,9 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
// (The preamble is supposed to be idempotent within a session.)
expect(privacyQuestions.length).toBe(1);
} finally {
// Restore ambient env before other tests.
if (origGstackHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = origGstackHome;
if (origPath === undefined) delete process.env.PATH;
else process.env.PATH = origPath;
fs.rmSync(gstackHome, { recursive: true, force: true });
fs.rmSync(fakeBinDir, { recursive: true, force: true });
fs.rmSync(tempHome, { recursive: true, force: true });
}
}, 180_000);
@@ -155,6 +156,10 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
// Same staging, but prompted=true this time. Gate should be silent.
const gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-'));
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-bin-'));
// Fresh HOME without a .claude.json — same rationale as the first test:
// without it the operator's ~/.claude.json flips the preamble into
// remote-mode and this negative test passes vacuously.
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-home-'));
fs.writeFileSync(
path.join(gstackHome, 'config.yaml'),
@@ -171,11 +176,13 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
const binary = resolveClaudeBinary();
// Ambient env mutations (see note on the first test).
const origGstackHome = process.env.GSTACK_HOME;
const origPath = process.env.PATH;
process.env.GSTACK_HOME = gstackHome;
process.env.PATH = `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
// Per-test env, merged LAST by the hermetic env builder (see note on the
// first test — ambient GSTACK_HOME mutation is scrubbed by hermetic-env).
const childEnv = {
GSTACK_HOME: gstackHome,
HOME: tempHome,
PATH: `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`,
};
try {
await runAgentSdkTest({
@@ -185,6 +192,7 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
workingDirectory: gstackHome,
maxTurns: 4,
allowedTools: ['Read', 'Grep', 'Glob', 'Bash'],
env: childEnv,
...(binary ? { pathToClaudeCodeExecutable: binary } : {}),
canUseTool: async (toolName, input) => {
if (toolName === 'AskUserQuestion') {
@@ -216,12 +224,9 @@ describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
});
expect(privacyQuestions.length).toBe(0);
} finally {
if (origGstackHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = origGstackHome;
if (origPath === undefined) delete process.env.PATH;
else process.env.PATH = origPath;
fs.rmSync(gstackHome, { recursive: true, force: true });
fs.rmSync(fakeBinDir, { recursive: true, force: true });
fs.rmSync(tempHome, { recursive: true, force: true });
}
}, 180_000);
});
+4 -2
View File
@@ -126,7 +126,7 @@ Write DESIGN.md and CLAUDE.md (or update it) in the working directory.`,
'Color': ['color', 'colour', 'palette', 'colors'],
'Spacing': ['spacing', 'space', 'whitespace', 'gap'],
'Layout': ['layout', 'grid', 'structure', 'composition'],
'Motion': ['motion', 'animation', 'transition', 'movement'],
'Motion': ['motion', 'animation', 'transition', 'movement', 'easing', 'duration', 'micro-interaction'],
};
const missingSections = Object.entries(sectionSynonyms).filter(
([_, synonyms]) => !synonyms.some(s => designContent.toLowerCase().includes(s))
@@ -152,7 +152,9 @@ Write DESIGN.md and CLAUDE.md (or update it) in the working directory.`,
expect(['success', 'error_max_turns']).toContain(result.exitReason);
expect(designExists).toBe(true);
if (designExists) {
expect(missingSections).toHaveLength(0);
// join() so a failure names the offending section(s) — a bare
// toHaveLength(0) failure never prints WHICH synonym set missed.
expect(missingSections.join(', ')).toBe('');
}
if (claudeExists) {
const claude = fs.readFileSync(claudePath, 'utf-8');
+7 -4
View File
@@ -81,7 +81,10 @@ Write your complete review directly to ${planDir}/review-output.md
Focus on reviewing the plan content: architecture, error handling, security, and performance.`,
workingDirectory: planDir,
maxTurns: 15,
timeout: 360_000,
// 540s: the evidence-before-claimed-limitations directive and the
// design-doc discovery block (fork port wave 2) add real probing turns;
// main cleared this at 243s, the enriched skill needs more headroom.
timeout: 540_000,
testName: 'plan-ceo-review',
runId,
model: 'claude-opus-4-7',
@@ -100,7 +103,7 @@ Focus on reviewing the plan content: architecture, error handling, security, and
const review = fs.readFileSync(reviewPath, 'utf-8');
expect(review.length).toBeGreaterThan(200);
}
}, 420_000);
}, 660_000);
});
// --- Plan CEO Review (SELECTIVE EXPANSION) E2E ---
@@ -168,7 +171,7 @@ Write your complete review directly to ${planDir}/review-output-selective.md
Focus on reviewing the plan content: architecture, error handling, security, and performance.`,
workingDirectory: planDir,
maxTurns: 15,
timeout: 360_000,
timeout: 540_000,
testName: 'plan-ceo-review-selective',
runId,
model: 'claude-opus-4-7',
@@ -185,7 +188,7 @@ Focus on reviewing the plan content: architecture, error handling, security, and
const review = fs.readFileSync(reviewPath, 'utf-8');
expect(review.length).toBeGreaterThan(200);
}
}, 420_000);
}, 660_000);
});
// --- Plan CEO Review SCOPE EXPANSION energy (V1.1 mode-posture regression gate) ---
+4 -2
View File
@@ -406,7 +406,9 @@ Do NOT fix any bugs. Do NOT use AskUserQuestion — just pick vitest.`,
}, 120_000);
});
// Module-level afterAll — finalize eval collector after all tests complete
// Module-level afterAll — finalize eval collector after all tests complete.
// Explicit 60s timeout: finalize does a JSON save + cross-run comparison and
// has been observed at 6.26s, past bun's 5s default hook timeout.
afterAll(async () => {
await finalizeEvalCollector(evalCollector);
});
}, 60_000);
+29 -12
View File
@@ -130,15 +130,16 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
const binary = resolveClaudeBinary();
// Ambient env mutations. Restored in finally.
const orig = {
gstackHome: process.env.GSTACK_HOME,
pathEnv: process.env.PATH,
mcpToken: process.env.GBRAIN_MCP_TOKEN,
// Per-test child env, passed via opts.env (merges last over the complete
// hermetic env). Ambient process.env mutations DO NOT reach children:
// hermetic-env scrubs GBRAIN_*/GSTACK_* by allowlist — this test's token
// silently never arrived from the day hermetic env landed, and the child
// correctly stopped at Step 4c with NEEDS_CONTEXT.
const childEnv = {
GSTACK_HOME: gstackHome,
GBRAIN_MCP_TOKEN: SECRET_TOKEN,
PATH: `${fakeBinDir}:${path.join(path.resolve(import.meta.dir, '..'), 'bin')}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`,
};
process.env.GSTACK_HOME = gstackHome;
process.env.PATH = `${fakeBinDir}:${path.join(path.resolve(import.meta.dir, '..'), 'bin')}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
process.env.GBRAIN_MCP_TOKEN = SECRET_TOKEN;
let modelTextOutput = '';
@@ -146,6 +147,7 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
const skillPath = path.resolve(import.meta.dir, '..', 'setup-gbrain', 'SKILL.md');
const result = await runAgentSdkTest({
systemPrompt: { type: 'preset', preset: 'claude_code' },
env: childEnv,
userPrompt:
`Read the skill file at ${skillPath} and follow Path 4 (Remote MCP) only. ` +
`Use this MCP URL: ${stubServer.url}. ` +
@@ -195,6 +197,19 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
// Assertion 2: claude mcp add was called with --transport http.
const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, 'utf-8') : '';
if (!/mcp add.*--transport http/.test(calls)) {
// Failure evidence: without this, the transcript dies with the test
// and every triage pass starts blind (three did).
const bashCmds = result.toolCalls
.filter((t) => t.tool === 'Bash')
.map((t) => String((t.input as { command?: string })?.command ?? '').slice(0, 200));
console.error(
`[setup-gbrain-remote] mcp-add never hit the fake shim.\n` +
`exitReason=${result.exitReason} turns=${result.turnsUsed}\n` +
`--- bash commands (${bashCmds.length}) ---\n${bashCmds.join('\n')}\n` +
`--- final text (last 1500 chars) ---\n${result.output.slice(-1500)}`,
);
}
expect(calls).toMatch(/mcp add.*--transport http/);
// Assertion 3: the secret token NEVER appears in the final CLAUDE.md.
@@ -207,14 +222,16 @@ describeE2E('/setup-gbrain Path 4 (Remote MCP) — happy path', () => {
// Assertion 5: classifier — the model didn't write findings before
// asking. The Path 4 prose has 5 STOP gates; if any of them got
// skipped, that's the wrote_findings_before_asking pattern.
const wroteBefore = /## GSTACK REVIEW REPORT|critical_gaps/i.test(modelTextOutput);
// Scan the ASSISTANT's text only: modelTextOutput serializes every
// event including the child's Read of the skill file, whose generated
// footer contains the literal "GSTACK REVIEW REPORT" — a guaranteed
// false positive on both trees.
const wroteBefore = /## GSTACK REVIEW REPORT|critical_gaps/i.test(result.output);
// Setup-gbrain doesn't have a review report contract, so this is
// a structural shape check, not a hard failure mode.
expect(wroteBefore).toBe(false);
} finally {
if (orig.gstackHome === undefined) delete process.env.GSTACK_HOME; else process.env.GSTACK_HOME = orig.gstackHome;
if (orig.pathEnv === undefined) delete process.env.PATH; else process.env.PATH = orig.pathEnv;
if (orig.mcpToken === undefined) delete process.env.GBRAIN_MCP_TOKEN; else process.env.GBRAIN_MCP_TOKEN = orig.mcpToken;
// (no ambient process.env mutations to restore — env goes via opts.env)
await stubServer.close();
fs.rmSync(gstackHome, { recursive: true, force: true });
fs.rmSync(fakeBinDir, { recursive: true, force: true });
+9 -3
View File
@@ -158,7 +158,7 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
const session = await launchClaudePty({
permissionMode: 'plan',
cwd: fixture.workTree,
timeoutMs: 720_000,
timeoutMs: 1_080_000,
// Disable network-y pieces so the agent can't reach actual github.
env: { GH_TOKEN: 'mock-not-real', NO_COLOR: '1' },
seedSkills: true,
@@ -172,7 +172,7 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
const since = session.mark();
session.send('/ship\r');
const budgetMs = 600_000;
const budgetMs = 900_000;
const start = Date.now();
let lastPermSig = '';
while (Date.now() - start < budgetMs) {
@@ -234,6 +234,12 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
break;
}
}
// Budget exhausted without a terminal signal: capture the tail NOW,
// while the session is still alive. Only the break paths above set
// evidence — without this, the timeout throw ships evidence: "".
if (outcome === 'timeout') {
evidence = session.visibleSince(since).slice(-3000);
}
} finally {
await session.close();
}
@@ -273,6 +279,6 @@ describeE2E('/ship idempotency E2E (periodic, real-PTY)', () => {
try { fs.rmSync(path.dirname(fixture.workTree), { recursive: true, force: true }); } catch { /* ignore */ }
}
},
900_000, // 15 min wall clock
1_200_000, // 20 min wall clock
);
});
+5 -3
View File
@@ -1216,9 +1216,11 @@ describe('Step 3.4 test coverage audit', () => {
describe('ship step numbering', () => {
// Allowed sub-steps that are resolver-generated and intentionally nested:
// 8.1 (Plan Verification), 8.2 (Scope Drift), 9.1 (Review Army), 9.2 (Findings Merge),
// 9.3 (Cross-review dedup), 15.0 (WIP squash — continuous checkpoint), 15.1 (Bisectable commits).
const ALLOWED_SUBSTEPS = new Set(['8.1', '8.2', '9.1', '9.2', '9.3', '15.0', '15.1']);
// 0.9 (Apple target detection — MUST precede Step 1's branch gate, R2-pinned
// by test/ship-apple-gate.test.ts), 8.1 (Plan Verification), 8.2 (Scope
// Drift), 9.1 (Review Army), 9.2 (Findings Merge), 9.3 (Cross-review dedup),
// 15.0 (WIP squash — continuous checkpoint), 15.1 (Bisectable commits).
const ALLOWED_SUBSTEPS = new Set(['0.9', '8.1', '8.2', '9.1', '9.2', '9.3', '15.0', '15.1']);
test('ship/SKILL.md.tmpl contains no unexpected fractional step numbers', () => {
const tmpl = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md.tmpl'), 'utf-8');
+106
View File
@@ -0,0 +1,106 @@
/**
* Regression pin for #2018: /autoplan Phase 4's task aggregator emitted zero
* tasks on every run, forever, for everyone.
*
* Root cause: the branch+commit filter in scripts/resolvers/tasks-section.ts
* piped to the split commit array and THEN referenced `.commit`
*
* select(.branch == $branch and ($commits | split("|") | index(.commit) != null))
*
* In jq, the pipe rebinds the context, so `.commit` was evaluated against the
* split ARRAY ("Cannot index array with string \"commit\""), every input line
* errored, stderr went to /dev/null, `|| true` swallowed the exit code, and
* the aggregate was empty. A dead feature indistinguishable from "no tasks".
*
* These tests run the ACTUAL jq program extracted from the resolver source
* against fixture JSONL, so they were RED against the broken filter and stay
* red if anyone reintroduces a context-rebinding shape.
*/
import { describe, it, expect } from "bun:test";
import { execFileSync } from "child_process";
import { readFileSync } from "fs";
import { join } from "path";
const SOURCE_PATH = join(import.meta.dir, "..", "scripts", "resolvers", "tasks-section.ts");
/**
* Extract the emitted jq filter program from the resolver source. The
* resolver builds bash inside a TS template literal, so `\\` in source is a
* bash line-continuation `\` — strip it when unescaping. We match the
* single-quoted jq program on the line that filters by $branch + $commits.
*/
function extractBranchCommitFilter(): string {
const src = readFileSync(SOURCE_PATH, "utf-8");
const m = src.match(/'([^']*select\(\.branch == \$branch[^']*)'/);
if (!m) throw new Error("branch+commit jq filter not found in tasks-section.ts");
return m[1].replace(/\\\\/g, "\\");
}
function runJq(program: string, inputLines: string[], branch: string, commits: string): string[] {
const out = execFileSync(
"jq",
["-c", "--arg", "branch", branch, "--arg", "commits", commits, program],
{ input: inputLines.join("\n"), encoding: "utf-8" },
);
return out.split("\n").filter(Boolean);
}
const RECORD = (branch: string, commit: string) =>
JSON.stringify({
phase: "ceo-review",
run_id: "20260814T000000Z-1",
branch,
commit,
id: "T1",
priority: "P1",
component: "demo",
files: ["a.ts"],
effort_human: "~1h",
effort_cc: "~5min",
title: "demo task",
source_finding: "demo finding",
});
describe("tasks-section jq filter (#2018)", () => {
it("matches a record whose branch and commit are in the window", () => {
const program = extractBranchCommitFilter();
const matched = runJq(
program,
[RECORD("feature/x", "abc123")],
"feature/x",
"abc123|def456",
);
// The broken filter returned [] here (every line errored) — the exact
// #2018 symptom: reviews produced tasks, the aggregate table showed none.
expect(matched).toHaveLength(1);
expect(JSON.parse(matched[0]).id).toBe("T1");
});
it("filters out other branches and out-of-window commits", () => {
const program = extractBranchCommitFilter();
const matched = runJq(
program,
[
RECORD("feature/x", "abc123"),
RECORD("other-branch", "abc123"),
RECORD("feature/x", "zzz999"),
],
"feature/x",
"abc123|def456",
);
expect(matched).toHaveLength(1);
});
it("errors on no input line at all rather than fabricating output", () => {
const program = extractBranchCommitFilter();
expect(runJq(program, [], "feature/x", "abc123")).toHaveLength(0);
});
it("source does not reference .commit after a context-rebinding pipe", () => {
const src = readFileSync(SOURCE_PATH, "utf-8");
// The bug shape: split("|") piped, then a bare `.commit` in the new array
// context. Binding first (`.commit as $c`) is the required form.
expect(src).not.toMatch(/split\("\|"\)\s*\|\s*index\(\.commit\)/);
});
});
+107 -24
View File
@@ -12,14 +12,20 @@
* (bin/gstack-telemetry-log)
*
* gstack-telemetry-sync MUST strip every one of those fields before the remote
* POST (bin/gstack-telemetry-sync). This test enforces that contract three ways:
* POST (bin/gstack-telemetry-sync). The script has TWO strip paths jq del()
* is PRIMARY (structural, escape-proof), sed is the jq-less fallback and
* this test enforces the contract on both:
*
* 1. Coverage every repo/branch field the producers emit is also stripped.
* Catches "added a new repo field, forgot to strip it" (the rename-to-_repo
* landmine, or any future producer drift).
* 2. Behavior run the ACTUAL sed strip expressions from the sync script over
* a sample event line and assert no repo/branch field survives, while benign
* fields do. Catches a broken/edited regex, not just a missing line.
* 1. Coverage every repo/branch field the producers emit is also stripped,
* by every jq del() list AND by the sed expressions. Catches "added a new
* repo field, forgot to strip it" (the rename-to-_repo landmine, or any
* future producer drift) on whichever path a machine takes.
* 2. Behavior run the ACTUAL jq expression and the ACTUAL sed strip
* expressions from the sync script over a sample event line and assert no
* repo/branch field survives, while benign fields do. Catches a
* broken/edited filter, not just a missing line. The jq leg also pins the
* malformed-line contract: a line jq can't parse is dropped, never
* forwarded unstripped.
* 3. Floor the three known fields are always in the stripped set, so deleting
* a strip rule fails CI even if a producer also stops emitting it.
*/
@@ -45,6 +51,16 @@ function extractSedExprs(scriptText: string): string[] {
return [...scriptText.matchAll(/-e\s+'(s\/[^']*)'/g)].map((m) => m[1]);
}
/** Pull every `jq -c 'del(...)'` filter out of the sync script, verbatim. */
function extractJqDelFilters(scriptText: string): string[] {
return [...scriptText.matchAll(/jq -c '(del\([^']*\))'/g)].map((m) => m[1]);
}
/** The JSON keys a jq del() filter removes, e.g. `del(._repo_slug, .repo)`. */
function fieldsFromJqDel(filter: string): string[] {
return [...filter.matchAll(/\.([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1]);
}
/** The JSON key a strip expression targets, e.g. `,"repo":"[^"]*"` -> `repo`. */
function fieldFromSedExpr(expr: string): string | null {
const m = expr.match(/,"([A-Za-z_][A-Za-z0-9_]*)":/);
@@ -73,6 +89,25 @@ describe('telemetry no-repo-identity-egress invariant', () => {
const strippedFields = new Set(
strippedRepoExprs.map(fieldFromSedExpr).filter((f): f is string => f !== null),
);
const jqFilters = extractJqDelFilters(syncText);
// Repo-identity fields the producers emit into the synced file — computed
// once, asserted against BOTH strip paths (jq primary, sed fallback). Only
// emission lines that target the synced file (skill-usage.jsonl) count: the
// preamble appends directly; gstack-telemetry-log builds the synced event
// with a `printf '{"v":1,...` line into $JSONL_FILE (= skill-usage.jsonl).
const preambleSynced = fs
.readFileSync(PREAMBLE, 'utf-8')
.split('\n')
.filter((l) => l.includes('skill-usage.jsonl'));
const telLogSynced = fs
.readFileSync(TEL_LOG, 'utf-8')
.split('\n')
.filter((l) => l.includes('"v":1') || l.includes('skill-usage'));
const emitted = new Set<string>([
...emittedRepoFields(preambleSynced),
...emittedRepoFields(telLogSynced),
]);
test('floor: the three known repo-identity fields are stripped', () => {
for (const field of REPO_IDENTITY_FLOOR) {
@@ -80,33 +115,34 @@ describe('telemetry no-repo-identity-egress invariant', () => {
}
});
test('coverage: every repo/branch field the producers emit into skill-usage.jsonl is stripped', () => {
// Only emission lines that target the synced file (skill-usage.jsonl). The
// preamble appends directly; gstack-telemetry-log builds the synced event
// with a `printf '{"v":1,...` line into $JSONL_FILE (= skill-usage.jsonl).
const preambleSynced = fs
.readFileSync(PREAMBLE, 'utf-8')
.split('\n')
.filter((l) => l.includes('skill-usage.jsonl'));
const telLogSynced = fs
.readFileSync(TEL_LOG, 'utf-8')
.split('\n')
.filter((l) => l.includes('"v":1') || l.includes('skill-usage'));
const emitted = new Set<string>([
...emittedRepoFields(preambleSynced),
...emittedRepoFields(telLogSynced),
]);
test('coverage: every repo/branch field the producers emit into skill-usage.jsonl is stripped (sed fallback path)', () => {
// The preamble must emit "repo" — guards against the test silently passing
// because a regex stopped matching the producer.
expect(emitted.has('repo')).toBe(true);
for (const field of emitted) {
expect(
strippedFields.has(field),
`producer emits repo-identity field "${field}" but gstack-telemetry-sync does not strip it (would leak to remote)`,
`producer emits repo-identity field "${field}" but gstack-telemetry-sync's sed fallback does not strip it (would leak to remote)`,
).toBe(true);
}
});
test('coverage: every jq del() list (the PRIMARY strip path) covers every emitted repo-identity field', () => {
// Both tiers run a del() filter; each must strip full repo identity on its
// own — a machine only ever takes one branch.
expect(jqFilters.length).toBeGreaterThanOrEqual(2);
expect(emitted.has('repo')).toBe(true); // producer-regex canary, as above
for (const filter of jqFilters) {
const delFields = new Set(fieldsFromJqDel(filter));
for (const field of [...emitted, ...REPO_IDENTITY_FLOOR]) {
expect(
delFields.has(field),
`jq filter "${filter}" does not del repo-identity field "${field}" (primary strip path would leak it to remote)`,
).toBe(true);
}
}
});
test('behavior: the real sed expressions remove repo identity, keep benign fields', () => {
const sample =
'{"v":1,"ts":"2026-06-02T00:00:00Z","skill":"design-shotgun",' +
@@ -134,4 +170,51 @@ describe('telemetry no-repo-identity-egress invariant', () => {
expect(cleaned).toContain('"sessions":3');
expect(cleaned).toContain('"ts":"2026-06-02T00:00:00Z"');
});
test('behavior: the real jq del() filters strip repo identity and drop malformed lines', () => {
if (!Bun.which('jq')) return; // jq-less machine: the sed-fallback behavior test above is the live path
const sample =
'{"v":1,"ts":"2026-06-02T00:00:00Z","skill":"design-shotgun",' +
'"repo":"my-secret-repo","_repo_slug":"acme-my-secret-repo","_branch":"feature-x",' +
'"sessions":3,"installation_id":"abc123"}';
// The identified-tier filter (no installation_id in its del list) and the
// anonymous-tier filter (installation_id included) — run each verbatim.
const identified = jqFilters.find((f) => !f.includes('installation_id'));
const anonymous = jqFilters.find((f) => f.includes('installation_id'));
expect(identified).toBeTruthy();
expect(anonymous).toBeTruthy();
const runJq = (filter: string, input: string) => {
const out = spawnSync(['jq', '-c', filter], { stdin: Buffer.from(input) });
return { exitCode: out.exitCode, stdout: out.stdout.toString().trim() };
};
const id = runJq(identified!, sample);
expect(id.exitCode).toBe(0);
// No repo/branch identity survives, value or key.
expect(id.stdout).not.toContain('my-secret-repo');
expect(id.stdout).not.toContain('feature-x');
expect(id.stdout).not.toContain('"repo"');
expect(id.stdout).not.toContain('_repo_slug');
expect(id.stdout).not.toContain('_branch');
// Benign fields are untouched; identified tier keeps installation_id.
expect(id.stdout).toContain('"skill":"design-shotgun"');
expect(id.stdout).toContain('"sessions":3');
expect(id.stdout).toContain('"installation_id":"abc123"');
// Anonymous tier additionally drops installation_id.
const anon = runJq(anonymous!, sample);
expect(anon.exitCode).toBe(0);
expect(anon.stdout).not.toContain('installation_id');
expect(anon.stdout).not.toContain('my-secret-repo');
// Malformed line: jq fails and emits nothing — the sync script's
// `|| CLEAN=""` + `[ -z "$CLEAN" ] && continue` drops it, so bytes the
// strip never touched are never forwarded.
const bad = runJq(identified!, '{"v":1,"repo":"my-secret-repo"');
expect(bad.exitCode).not.toBe(0);
expect(bad.stdout).toBe('');
});
});
+359
View File
@@ -0,0 +1,359 @@
/**
* gstack-verify-gate Stop-hook enforcement tier.
*
* Pins the behaviours the gate exists for:
* trust a declared command NEVER runs until the user records it via
* `gstack-verify-gate --trust` (per-repo command trust store).
* block trusted check fails, exit 2, turn cannot end.
* allow trusted check passes, exit 0.
* fail open nothing declared, exit 0. Absence never blocks.
*
* Plus the two safety branches: the Stop re-entry guard, and the static
* opt-in contract (our adaptation): ./setup never registers the gate; the
* settings-hook helper.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createHash } from 'crypto';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const GATE = path.join(ROOT, 'bin', 'gstack-verify-gate');
let dir: string;
let gstackHome: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-verify-gate-'));
gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-verify-gate-home-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(gstackHome, { recursive: true, force: true });
});
/** Declare a verification command in the project's CLAUDE.md (comment form). */
function declareCheck(command: string): void {
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), `# Fixture\n\n<!-- gstack:verify: ${command} -->\n`);
}
/** Write the check script the declaration points at. Touches `check-ran` when executed. */
function check(exitCode: number, message: string): void {
const script = path.join(dir, 'check.sh');
fs.writeFileSync(script, `#!/bin/sh\ntouch check-ran\necho "${message}"\nexit ${exitCode}\n`);
fs.chmodSync(script, 0o755);
}
/** Did the declared check actually execute? */
function checkRan(): boolean {
return fs.existsSync(path.join(dir, 'check-ran'));
}
interface RunOpts {
stopHookActive?: boolean;
cwd?: string;
/** When false, CLAUDE_PROJECT_DIR is removed from the child env (walk-up mode). */
projectDirEnv?: boolean;
/** Hook-input session id, keys the re-entry attempt counter. */
sessionId?: string;
}
function gateEnv(projectDirEnv: boolean): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, GSTACK_HOME: gstackHome };
if (projectDirEnv) env.CLAUDE_PROJECT_DIR = dir;
else delete env.CLAUDE_PROJECT_DIR;
return env;
}
function runGate(opts: RunOpts = {}): { code: number; stdout: string; stderr: string } {
const input: Record<string, unknown> = { stop_hook_active: opts.stopHookActive ?? false };
if (opts.sessionId) input.session_id = opts.sessionId;
const r = spawnSync(GATE, {
cwd: opts.cwd ?? dir,
input: JSON.stringify(input),
encoding: 'utf-8',
timeout: 15000,
env: gateEnv(opts.projectDirEnv ?? true),
});
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
}
/** Record the currently-declared command in the trust store. */
function trust(opts: RunOpts = {}): { code: number; stdout: string; stderr: string } {
const r = spawnSync(GATE, ['--trust'], {
cwd: opts.cwd ?? dir,
encoding: 'utf-8',
timeout: 15000,
env: gateEnv(opts.projectDirEnv ?? true),
});
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
}
describe('gstack-verify-gate trust store', () => {
test('an untrusted declared command is NOT executed and does not block', () => {
declareCheck('touch sentinel-ran');
const r = runGate();
expect(r.code).toBe(0);
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false);
expect(r.stderr).toContain('--trust');
expect(r.stderr).toContain('touch sentinel-ran');
});
test('--trust records the command and prints a confirmation naming it', () => {
declareCheck('./check.sh');
check(0, 'all good');
const t = trust();
expect(t.code).toBe(0);
expect(t.stdout).toContain('./check.sh');
});
test('after --trust the hook executes the command and block semantics work', () => {
declareCheck('./check.sh');
check(1, 'totals mismatch');
expect(trust().code).toBe(0);
const r = runGate();
expect(checkRan()).toBe(true);
expect(r.code).toBe(2);
expect(r.stderr).toContain('FAILED');
expect(r.stderr).toContain('totals mismatch');
});
test('a changed command is not executed until re-trusted', () => {
declareCheck('true');
expect(trust().code).toBe(0);
// Attacker (or anyone) edits the declaration after trust was granted.
declareCheck('touch sentinel-ran');
const r = runGate();
expect(r.code).toBe(0);
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false);
expect(r.stderr).toContain('--trust');
// Re-trusting the new command restores execution.
expect(trust().code).toBe(0);
const r2 = runGate();
expect(r2.code).toBe(0);
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(true);
});
test('walk-up: hook run from a nested subdir keys trust on the CLAUDE.md root', () => {
declareCheck('./check.sh');
check(0, 'all good');
const nested = path.join(dir, 'a', 'b');
fs.mkdirSync(nested, { recursive: true });
// No CLAUDE_PROJECT_DIR: both trust and hook must walk up from $PWD.
expect(trust({ cwd: nested, projectDirEnv: false }).code).toBe(0);
const r = runGate({ cwd: nested, projectDirEnv: false });
expect(r.code).toBe(0);
expect(r.stdout).toContain('passed');
expect(checkRan()).toBe(true);
});
test('non-comment declaration form (gstack:verify: cmd without <!-- -->) is honored', () => {
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\ngstack:verify: ./check.sh\n');
check(0, 'all good');
expect(trust().stdout).toContain('./check.sh');
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain('passed');
expect(checkRan()).toBe(true);
});
test('the trust store file is created 0600 under GSTACK_HOME', () => {
declareCheck('./check.sh');
check(0, 'all good');
expect(trust().code).toBe(0);
const store = path.join(gstackHome, 'verify-gate-trust');
expect(fs.existsSync(store)).toBe(true);
expect(fs.statSync(store).mode & 0o777).toBe(0o600);
});
test('--trust fails cleanly when nothing is declared', () => {
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\nNothing declared here.\n');
const t = trust();
expect(t.code).not.toBe(0);
});
});
describe('gstack-verify-gate', () => {
test('blocks the turn when the trusted check fails', () => {
declareCheck('./check.sh');
check(1, 'totals mismatch');
expect(trust().code).toBe(0);
const r = runGate();
expect(r.code).toBe(2);
expect(r.stderr).toContain('FAILED');
expect(r.stderr).toContain('totals mismatch');
});
test('allows the turn when the trusted check passes', () => {
declareCheck('./check.sh');
check(0, 'all good');
expect(trust().code).toBe(0);
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain('passed');
});
test('fails open when CLAUDE.md declares no check', () => {
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\nNothing declared here.\n');
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain("declares no 'gstack:verify:' command");
});
test('fails open when there is no CLAUDE.md at all', () => {
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain('no CLAUDE.md');
});
test('never invents a command: an empty declaration fails open', () => {
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '<!-- gstack:verify: -->\n');
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain("declares no 'gstack:verify:' command");
});
});
describe('gstack-verify-gate re-entry enforcement (no one-shot bypass)', () => {
test('re-entry with a still-failing trusted check is blocked again', () => {
declareCheck('./check.sh');
check(1, 'still failing');
expect(trust().code).toBe(0);
const r = runGate({ stopHookActive: true, sessionId: 'sess-refail' });
expect(r.code).toBe(2);
expect(r.stderr).toContain('FAILED');
expect(r.stderr).toContain('still failing');
expect(checkRan()).toBe(true);
});
test('re-entry after the check now passes is allowed', () => {
declareCheck('./check.sh');
check(1, 'totals mismatch');
expect(trust().code).toBe(0);
expect(runGate({ sessionId: 'sess-fixed' }).code).toBe(2);
check(0, 'fixed now');
const r = runGate({ stopHookActive: true, sessionId: 'sess-fixed' });
expect(r.code).toBe(0);
expect(r.stdout).toContain('passed');
});
test('attempt bound: repeated failing re-entries allow with a loud warning at the bound', () => {
declareCheck('./check.sh');
check(1, 'never passing');
expect(trust().code).toBe(0);
const sid = 'sess-bound';
// First entry blocks and resets the episode counter.
expect(runGate({ sessionId: sid }).code).toBe(2);
// Re-entries: bounded number of blocks, then allow-with-warning.
const codes: number[] = [];
let final: { code: number; stdout: string; stderr: string } | null = null;
for (let i = 0; i < 6; i++) {
const r = runGate({ stopHookActive: true, sessionId: sid });
codes.push(r.code);
if (r.code === 0) {
final = r;
break;
}
expect(r.code).toBe(2);
}
expect(codes).toEqual([2, 2, 2, 0]);
expect(final).not.toBeNull();
expect(final!.stdout + final!.stderr).toContain('WARNING');
// A fresh first-entry run starts a new episode: blocked again, not allowed.
expect(runGate({ sessionId: sid }).code).toBe(2);
});
test('re-entry with an untrusted command keeps the exit-0-with-hint path', () => {
declareCheck('touch sentinel-ran');
const r = runGate({ stopHookActive: true, sessionId: 'sess-untrusted' });
expect(r.code).toBe(0);
expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false);
expect(r.stderr).toContain('--trust');
});
});
describe('gstack-verify-gate trust-grant audit trail', () => {
test('every --trust grant appends a JSON audit line (right sha256, 0600, verbatim cmd)', () => {
declareCheck('./check.sh');
check(0, 'all good');
const t = trust();
expect(t.code).toBe(0);
// --trust prints the VERBATIM command being trusted.
expect(t.stdout).toContain('./check.sh');
const log = path.join(gstackHome, 'security', 'verify-gate-trust-grants.jsonl');
expect(fs.existsSync(log)).toBe(true);
expect(fs.statSync(log).mode & 0o777).toBe(0o600);
const lines = fs.readFileSync(log, 'utf-8').trim().split('\n');
expect(lines.length).toBe(1);
const entry = JSON.parse(lines[0]);
expect(entry.cmd).toBe('./check.sh');
expect(entry.cmd_sha256).toBe(createHash('sha256').update('./check.sh').digest('hex'));
expect(entry.root).toBe(fs.realpathSync(dir));
expect(typeof entry.tty).toBe('boolean');
expect(entry.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
// A second grant appends, never truncates.
declareCheck('true');
expect(trust().code).toBe(0);
const lines2 = fs.readFileSync(log, 'utf-8').trim().split('\n');
expect(lines2.length).toBe(2);
expect(JSON.parse(lines2[1]).cmd).toBe('true');
});
});
describe('opt-in contract (adapted from the fork: NOT registered by default)', () => {
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const gate = fs.readFileSync(GATE, 'utf-8');
test('./setup does NOT register the gate — a Stop hook running the verify command after every turn is opt-in', () => {
expect(setup).not.toContain('verify-gate');
});
test('the bin documents its own registration and removal commands', () => {
expect(gate).toContain('remove-source --source verify-gate');
expect(gate).toContain('gstack:verify:');
});
});