diff --git a/.gitattributes b/.gitattributes index e67042f0ab..10a4ff7fd1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,7 @@ *.yaml text eol=lf *.json text eol=lf *.toml text eol=lf +*.txt text eol=lf # Bash scripts must always use LF — CRLF in bash scripts produces bizarre # "Bad interpreter" / "command not found" errors on Linux runners. diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci index fed2210058..550c90d3a8 100644 --- a/.github/docker/Dockerfile.ci +++ b/.github/docker/Dockerfile.ci @@ -104,7 +104,12 @@ RUN for i in 1 2 3; do \ # resolution. Without bun.lock here, bun install resolved transitive deps # differently in CI vs local (observed on v1.28.0.0: socks landed but # smart-buffer + ip-address didn't make it into the cached node_modules). +# patches/ rides along: bun.lock's patchedDependencies (playwright-core +# windowsHide, v1.67) makes install fail without the patch files present — +# and the workflows' image-tag hash includes patches/** so editing a patch +# rebuilds this layer. COPY package.json bun.lock /workspace/ +COPY patches /workspace/patches WORKDIR /workspace RUN bun install --frozen-lockfile && rm -rf /tmp/* diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index a6e203ee3d..544f694fd6 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -22,7 +22,7 @@ jobs: actionlint: runs-on: ubicloud-standard-2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false # Pull the prebuilt image instead of rhysd/actionlint@v1.7.11 (a Docker diff --git a/.github/workflows/ci-image.yml b/.github/workflows/ci-image.yml index 2cb916063f..4cb1dccc27 100644 --- a/.github/workflows/ci-image.yml +++ b/.github/workflows/ci-image.yml @@ -20,18 +20,18 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # Copy lockfile + package.json into Docker build context - - run: cp package.json bun.lock .github/docker/ + - run: cp package.json bun.lock .github/docker/ && cp -R patches .github/docker/patches # Same content-hash tag expression as evals.yml / evals-periodic.yml. # This is the tag the eval matrix looks up first — without pushing it # here, the weekly/main prebuild never warms the cache that matters. - id: meta - run: echo "tag=ghcr.io/${{ github.repository }}/ci:${{ hashFiles('.github/docker/Dockerfile.ci', 'bun.lock') }}" >> "$GITHUB_OUTPUT" + run: echo "tag=ghcr.io/${{ github.repository }}/ci:${{ hashFiles('.github/docker/Dockerfile.ci', 'bun.lock', 'patches/**') }}" >> "$GITHUB_OUTPUT" - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -39,9 +39,9 @@ jobs: # Registry cache export needs a docker-container builder — the default # `docker` driver hard-errors on cache-to. - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 - - uses: docker/build-push-action@v6 + - uses: docker/build-push-action@v7 with: context: .github/docker file: .github/docker/Dockerfile.ci diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index c69dad8d1b..b600ada817 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -24,8 +24,8 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: fail-on-severity: high fail-on-scopes: runtime, development diff --git a/.github/workflows/evals-periodic.yml b/.github/workflows/evals-periodic.yml index 2bbfb5bf96..b1493bc3fc 100644 --- a/.github/workflows/evals-periodic.yml +++ b/.github/workflows/evals-periodic.yml @@ -22,14 +22,14 @@ jobs: outputs: image-tag: ${{ steps.meta.outputs.tag }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - id: meta # Keep in sync with evals.yml — key on Dockerfile + lockfile only # (package.json's version field would bust the key on every ship). - run: echo "tag=${{ env.IMAGE }}:${{ hashFiles('.github/docker/Dockerfile.ci', 'bun.lock') }}" >> "$GITHUB_OUTPUT" + run: echo "tag=${{ env.IMAGE }}:${{ hashFiles('.github/docker/Dockerfile.ci', 'bun.lock', 'patches/**') }}" >> "$GITHUB_OUTPUT" - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -45,15 +45,15 @@ jobs: fi - if: steps.check.outputs.exists == 'false' - run: cp package.json bun.lock .github/docker/ + run: cp package.json bun.lock .github/docker/ && cp -R patches .github/docker/patches # Registry cache export needs a docker-container builder — the default # `docker` driver hard-errors on cache-to. - if: steps.check.outputs.exists == 'false' - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - if: steps.check.outputs.exists == 'false' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: .github/docker file: .github/docker/Dockerfile.ci @@ -103,7 +103,7 @@ jobs: - name: e2e-gemini file: test/gemini-e2e.test.ts steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -141,7 +141,7 @@ jobs: - name: Upload eval results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: eval-periodic-${{ matrix.suite.name }} path: ~/.gstack-dev/evals/*.json diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index 5400b19d45..fffb6cda13 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -28,7 +28,7 @@ jobs: outputs: image-tag: ${{ steps.meta.outputs.tag }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - id: meta # Key on Dockerfile + lockfile only. package.json is deliberately NOT @@ -36,9 +36,9 @@ jobs: # which rebuilt the image each time for a dependency set that only # bun.lock determines. A stale baked package.json is harmless — checkout # overwrites /workspace and node_modules comes from the lockfile. - run: echo "tag=${{ env.IMAGE }}:${{ hashFiles('.github/docker/Dockerfile.ci', 'bun.lock') }}" >> "$GITHUB_OUTPUT" + run: echo "tag=${{ env.IMAGE }}:${{ hashFiles('.github/docker/Dockerfile.ci', 'bun.lock', 'patches/**') }}" >> "$GITHUB_OUTPUT" - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -54,7 +54,7 @@ jobs: fi - if: steps.check.outputs.exists == 'false' - run: cp package.json bun.lock .github/docker/ + run: cp package.json bun.lock .github/docker/ && cp -R patches .github/docker/patches # A fork PR's GITHUB_TOKEN only has `packages: read`, so pushing fails. # Still BUILD (validates Dockerfile.ci changes), just don't publish. This @@ -63,10 +63,10 @@ jobs: # Registry cache export needs a docker-container builder — the default # `docker` driver hard-errors on cache-to (first live run of the trio). - if: steps.check.outputs.exists == 'false' - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - if: steps.check.outputs.exists == 'false' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: .github/docker file: .github/docker/Dockerfile.ci @@ -158,7 +158,7 @@ jobs: # row keeps --retry 1. retries: 2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 @@ -320,7 +320,7 @@ jobs: - name: Upload eval results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: eval-${{ matrix.suite.name }} path: ~/.gstack-dev/evals/*.json @@ -341,12 +341,12 @@ jobs: # early and never hit it, which is why this stayed hidden). See #1802 CI fix. issues: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 1 - name: Download all eval artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: eval-* path: /tmp/eval-results diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index 9296e3928b..7827277726 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -48,7 +48,7 @@ jobs: runs-on: ubicloud-standard-8 timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -56,7 +56,7 @@ jobs: with: bun-version: 1.3.13 - - uses: actions/cache@v4 + - uses: actions/cache@v6 with: path: ~/.bun/install/cache key: linux-bun-${{ hashFiles('bun.lock') }} @@ -67,7 +67,7 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - uses: actions/cache@v4 + - uses: actions/cache@v6 with: path: ~/.cache/ms-playwright key: linux-playwright-${{ hashFiles('bun.lock') }} @@ -118,7 +118,7 @@ jobs: # need a local re-run, which fork contributors can't do on this image. - name: Upload shard logs on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: free-test-shard-logs path: /tmp/gstack-free-test-*.log diff --git a/.github/workflows/make-pdf-gate.yml b/.github/workflows/make-pdf-gate.yml index ec52e69969..fa98082764 100644 --- a/.github/workflows/make-pdf-gate.yml +++ b/.github/workflows/make-pdf-gate.yml @@ -40,7 +40,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index 22b8ae6eb3..a5f4131ff0 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -18,7 +18,7 @@ jobs: actions: read contents: read security-events: write - uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@3adb4b14a2b0623876d18d863a498b785fb3752d # v2.3.8 + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@f4cfcc01edc9c8b756a9b873b7a623ca674da51e # v2.3.8 with: scan-args: |- --include-git-root diff --git a/.github/workflows/pr-title-sync.yml b/.github/workflows/pr-title-sync.yml index 9534e42776..5a01ae2754 100644 --- a/.github/workflows/pr-title-sync.yml +++ b/.github/workflows/pr-title-sync.yml @@ -39,7 +39,7 @@ jobs: steps: # Base repo only — trusted infra (the rewrite helper). No PR-head checkout. - name: Checkout base repo (trusted) - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 1 diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index af1c6fb121..5760d1a6b0 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -31,7 +31,7 @@ jobs: runs-on: ubicloud-standard-8 timeout-minutes: 20 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 with: fetch-depth: 0 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 diff --git a/.github/workflows/skill-docs.yml b/.github/workflows/skill-docs.yml index f9833426f3..47ba5f36bf 100644 --- a/.github/workflows/skill-docs.yml +++ b/.github/workflows/skill-docs.yml @@ -17,7 +17,7 @@ jobs: check-freshness: runs-on: ubicloud-standard-2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 - run: bun install # One generation pass for ALL 10 hosts. gen-skill-docs --host all diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 0e42a2dc96..00a2e25ecd 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -20,7 +20,7 @@ jobs: pull-requests: read steps: - name: Checkout PR head - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/windows-free-tests.yml b/.github/workflows/windows-free-tests.yml index 3ac871e5d6..67e5cd0b85 100644 --- a/.github/workflows/windows-free-tests.yml +++ b/.github/workflows/windows-free-tests.yml @@ -39,16 +39,16 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.13 # bun install was 35s of a 55s job, all network. Cache keyed on the # lockfile; bun's install cache lives under ~/.bun/install/cache on # every platform. - - uses: actions/cache@v4 + - uses: actions/cache@v6 with: path: ~/.bun/install/cache key: windows-bun-${{ hashFiles('bun.lock') }} @@ -119,9 +119,12 @@ jobs: # Same diagnosability contract as free-tests.yml: a red lane must # carry the WHY (the runner's quiet console names files, not causes). + # (#2561 was written against the old hand-listed subset; its two new + # test files are pure-TS and flow into the --windows-only curation + # automatically, so no per-file entry is needed here.) - name: Upload shard logs on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: windows-free-test-shard-logs path: ${{ runner.temp }}/gstack-free-test-*.log diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 90bb6f0b28..7d2014a2f3 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -35,15 +35,15 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.13 # Same lockfile-keyed install cache as windows-free-tests.yml (install # was 45s of a 64s job, all network). - - uses: actions/cache@v4 + - uses: actions/cache@v6 with: path: ~/.bun/install/cache key: windows-bun-${{ hashFiles('bun.lock') }} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1856f0ad70..cad068f5fa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -69,11 +69,11 @@ The server writes `.gstack/browse.json` (atomic write via tmp + rename, mode 0o6 { "pid": 12345, "port": 34567, "token": "uuid-v4", "startedAt": "...", "binaryVersion": "abc123" } ``` -The CLI reads this file to find the server. If the file is missing or the server fails an HTTP health check, the CLI spawns a new server. On Windows, PID-based process detection is unreliable in Bun binaries, so the health check (GET /health) is the primary liveness signal on all platforms. +The CLI reads this file to find the server. If the file is missing or the daemon process is dead, the CLI spawns a new server. A process that is alive but not answering `/health` is busy, not dead: the CLI probes for a bounded ~8s, then reports busy with a nonzero exit — only an explicit `--force-restart` kills a live daemon. Process liveness uses signal-0 (`isProcessAlive`, EPERM counts as alive) on every platform, with the health check (GET /health) as the responsiveness signal. Daemon stdout/stderr persists to `/.gstack/browse-daemon.log`. ### Port selection -Random port between 10000-60000 (retry up to 5 on collision). This means 10 Conductor workspaces can each run their own browse daemon with zero configuration and zero port conflicts. The old approach (scanning 9400-9409) broke constantly in multi-workspace setups. +Random port between 10000-49151 (retry up to 5 on collision), allocated through the shared `browse/src/port-allocator.ts` so every long-lived gstack listener draws from the same range. The range ends at 49151 on purpose: 49152-65535 is the macOS ephemeral pool, and allocating inside it meant the OS could hand the same port to another process moments later. This means 10 Conductor workspaces can each run their own browse daemon with zero configuration and zero port conflicts. The old approach (scanning 9400-9409) broke constantly in multi-workspace setups. ### Version auto-restart @@ -176,19 +176,19 @@ The Chrome sidebar agent has tools (Bash, Read, Glob, Grep, WebFetch) and reads 1. **L1-L3 content security (`browse/src/content-security.ts`).** Runs on every page-content command and every tool output: datamarking, hidden-element strip, ARIA regex, URL blocklist, and a trust-boundary envelope wrapper. Applied at both the server and the agent. -2. **L4 ML classifier — TestSavantAI (`browse/src/security-classifier.ts`).** A 22MB BERT-small ONNX model (int8 quantized) bundled with the agent. Runs locally, no network. Scans every user message and every Read/Glob/Grep/WebFetch tool output before Claude sees it. Opt-in 721MB DeBERTa-v3 ensemble via `GSTACK_SECURITY_ENSEMBLE=deberta`. +2. **L4 ML classifier — TestSavantAI (`browse/src/security-classifier.ts`).** A 22MB BERT-small ONNX model (int8 quantized) running in the security sidecar subprocess. Runs locally, no network. Scans page-derived content on the inject-scan path before the agent sees it. -3. **L4b transcript classifier.** A Claude Haiku pass that looks at the full conversation shape (user message, tool calls, tool output), not just text. Gated by `LOG_ONLY: 0.40` so most clean traffic skips the paid call. +3. **L4b transcript classifier (removed).** A Claude Haiku conversation-shape pass existed until the chat-path agent that invoked it was ripped; it was deleted as dead code (zero production callers), along with the opt-in DeBERTa ensemble. Do not re-document either as live. -4. **L5 canary token (`browse/src/security.ts`).** A random token injected into the system prompt at session start. Rolling-buffer detection across `text_delta` and `input_json_delta` streams catches the token if it shows up anywhere in Claude's output, tool arguments, URLs, or file writes. Deterministic BLOCK — if the token leaks, the attacker convinced Claude to reveal the system prompt, and the session ends. +4. **L5 canary token (`browse/src/security.ts`).** Generate/inject/detect utilities for a random system-prompt token whose leak means the attacker convinced the model to reveal the system prompt. Canary leak BLOCKs deterministically. The utilities are pure and tested; the chat prompt-builder that injected the canary was ripped, so no production path injects it today. 5. **L6 ensemble combiner (`combineVerdict`).** BLOCK requires agreement from two ML classifiers at >= `WARN` (0.75), not a single confident hit. This is the Stack Overflow instruction-writing false-positive mitigation. On tool-output scans, single-layer high confidence BLOCKs directly — the content wasn't user-authored, so the FP concern doesn't apply. -**Critical constraint:** `security-classifier.ts` runs only in the sidebar-agent process, never in the compiled browse binary. `@huggingface/transformers` v4 requires `onnxruntime-node`, which fails `dlopen` from Bun compile's temp extract directory. Only the pure-string pieces (canary inject/check, verdict combiner, attack log, status) are in `security.ts`, which is safe to import from `server.ts`. +**Critical constraint:** `security-classifier.ts` runs only in the security sidecar subprocess (`security-sidecar-entry.ts`), never in the compiled browse binary. `@huggingface/transformers` v4 requires `onnxruntime-node`, which fails `dlopen` from Bun compile's temp extract directory. Only the pure-string pieces (canary inject/check, verdict combiner) are in `security.ts`, which is safe to import from `server.ts`. (The attack log lives in `tunnel-denial-log.ts`; the session-state/status surface was removed in #2557.) -**Env knobs:** `GSTACK_SECURITY_OFF=1` is a real kill switch (skips ML scan, canary still injects). Model cache at `~/.gstack/models/testsavant-small/` (112MB, first run) and `~/.gstack/models/deberta-v3-injection/` (721MB, opt-in only). Attack log at `~/.gstack/security/attempts.jsonl` (salted sha256 + domain, rotates at 10MB, 5 generations). Per-device salt at `~/.gstack/security/device-salt` (0600), cached in-process to survive FS-unwritable environments. +**Env knobs:** `GSTACK_SECURITY_OFF=1` is a real kill switch (classifier stays off even if warmed; the L1-L3 filters keep running). Model cache at `~/.gstack/models/testsavant-small/` (112MB, first run). Attack log at `~/.gstack/security/attempts.jsonl` (salted sha256 + domain, rotates at 10MB, 5 generations). Per-device salt at `~/.gstack/security/device-salt` (0600), cached in-process to survive FS-unwritable environments. -**Visibility.** The sidebar header shows a shield icon (green/amber/red) polled via `/sidebar-chat`. A centered banner appears on canary leak or BLOCK verdict with the exact layer scores. `bin/gstack-security-dashboard` aggregates local attempts; `supabase/functions/community-pulse` aggregates opt-in community telemetry across users. +**Visibility.** A centered banner appears on canary leak or BLOCK verdict with the exact layer scores. `bin/gstack-security-dashboard` aggregates local attempts; `supabase/functions/community-pulse` aggregates opt-in community telemetry across users. (The sidebar header's SEC shield icon and the `/health` `security` field were removed in #2557: their only data source — `~/.gstack/security/session-state.json` — lost its only writer when the chat-path agent was ripped, so the shield reported stale or empty state. The live defenses report through their own call sites.) ## The ref system diff --git a/BROWSER.md b/BROWSER.md index 1ab7a1e600..dfd4774bf8 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -168,8 +168,18 @@ for the full design + decision trail. 1. **First call.** CLI checks `/.gstack/browse.json` for a running server. None found — it spawns `bun run browse/src/server.ts` in the background. Daemon launches headless Chromium via Playwright, picks a - random port (10000–60000), generates a bearer token, writes the state - file (chmod 600), starts accepting requests. ~3 seconds. + random port (10000–49151, deliberately below the macOS ephemeral pool + 49152-65535 so the OS never hands a colliding port to another process), + generates a bearer token, writes the state file (chmod 600), starts + accepting requests. ~3 seconds. One launch-time exception to fail-fast: + when a macOS XProtect definition update SIGKILLs the pinned Chromium at + spawn, the daemon classifies the kill signature, clears the quarantine + flag on the Playwright cache, reinstalls the pinned revision from the + gstack install root (bounded ~120s), and retries once — at most once per + daemon process. If the heal can't complete, the original launch error + plus manual `bunx playwright install chromium` guidance lands on daemon + stderr (see `browse-daemon.log`). Wired at all three launch sites in + `browser-manager.ts` via `browse/src/xprotect-heal.ts`. 2. **Subsequent calls.** CLI reads the state file, sends an HTTP POST with the bearer token, prints the response. ~100-200ms round trip. 3. **Idle shutdown.** After 30 minutes of no commands, daemon shuts down and @@ -177,6 +187,13 @@ for the full design + decision trail. 4. **Crash recovery.** If Chromium crashes, the daemon exits immediately — no self-healing, don't hide failure. CLI detects the dead daemon on the next call and starts a fresh one. +5. **Busy vs dead.** A daemon that stops answering HTTP while its process is + alive is busy, not dead. The CLI gives `/health` a bounded ~8s to recover, + then reports busy with a nonzero exit — it never kills an alive pid. + Only an explicit `--force-restart` replaces a live-but-unresponsive + daemon (tabs, cookies, and logins are lost). `browse stop` against a + daemon that already died is success: the desired end state holds, so it + cleans the stale state file instead of booting a daemon just to stop it. ### Multi-workspace isolation @@ -186,8 +203,8 @@ collisions. State at `/.gstack/browse.json`. | Workspace | State file | Port | |-----------|-----------|------| -| `/code/project-a` | `/code/project-a/.gstack/browse.json` | random (10000–60000) | -| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (10000–60000) | +| `/code/project-a` | `/code/project-a/.gstack/browse.json` | random (10000–49151) | +| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (10000–49151) | --- @@ -311,7 +328,7 @@ from `snapshot`, or `@c` refs from `snapshot -C`. Full table: | Command | Description | |---------|-------------| | `status` | Daemon health + mode (headless / headed / cdp) | -| `stop` | Shut down daemon | +| `stop` | Shut down daemon (succeeds even if the daemon already died — never boots one just to stop it) | | `restart` | Restart daemon | | `connect` | Launch headed GStack Browser with Side Panel extension | | `disconnect` | Close headed Chrome, return to headless | @@ -319,6 +336,12 @@ from `snapshot`, or `@c` refs from `snapshot -C`. Full table: | `state save\|load ` | Save or load browser state (cookies + URLs) | | `memory [--json]` | Snapshot Bun heap + per-tab JS heap + Chromium process tree + bounded buffer sizes. Use `--json` for programmatic consumers; text mode renders sorted top-10 tabs with "and N more" tail. | +The daemon's own stdout/stderr persists to `/.gstack/browse-daemon.log` +(append mode, rotated to `.log.1` at the size cap, single generation), with +tokens and unsanitized page content kept out — check it when a daemon dies +without an obvious cause. A live-but-unresponsive daemon is never auto-killed; +pass `--force-restart` to replace it explicitly (see "Daemon lifecycle" above). + ### Handoff | Command | Description | @@ -880,10 +903,11 @@ sidebar chat pipeline that hosted them. **Canary leak always BLOCKs - Attack log: `~/.gstack/security/attempts.jsonl` (salted SHA-256 + domain only, rotates at 10MB, 5 generations). - Per-device salt: `~/.gstack/security/device-salt` (0600). -- Session state: `~/.gstack/security/session-state.json` (cross-process, - atomic). -A shield icon in the sidebar header shows the live status. See +There is no security status indicator in the sidebar and no `security` +field on `/health` (#2557): the session-state file that fed them lost its +only writer when the chat-path agent was removed, so they reported stale or +empty data. The live defenses report through their own call sites. See ARCHITECTURE.md § "Prompt injection defense" for the full threat model. --- @@ -1209,8 +1233,8 @@ collisions. | Workspace | State file | Port | |-----------|-----------|------| -| `/code/project-a` | `/code/project-a/.gstack/browse.json` | random (10000–60000) | -| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (10000–60000) | +| `/code/project-a` | `/code/project-a/.gstack/browse.json` | random (10000–49151) | +| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (10000–49151) | Browser-skills three-tier lookup walks project → global → bundled, so a project-tier skill at `/code/project-a/.gstack/browser-skills/foo/` shadows @@ -1222,7 +1246,7 @@ the global `~/.gstack/browser-skills/foo/` only inside project-a. | Variable | Default | Description | |----------|---------|-------------| -| `BROWSE_PORT` | 0 (random 10000–60000) | Fixed port for the HTTP server (debug override) | +| `BROWSE_PORT` | 0 (random 10000–49151) | Fixed port for the HTTP server (debug override) | | `BROWSE_IDLE_TIMEOUT` | 1800000 (30 min) | Idle shutdown timeout in ms | | `BROWSE_STATE_FILE` | `.gstack/browse.json` | Path to state file | | `BROWSE_SERVER_SCRIPT` | auto-detected | Path to `server.ts` | @@ -1249,6 +1273,8 @@ browse/ │ ├── cli.ts # Thin client — reads state, sends HTTP, prints │ ├── server.ts # Bun HTTP daemon — routes commands, dual-listener │ ├── browser-manager.ts # Chromium lifecycle, tabs, ref map, crash detection +│ ├── port-allocator.ts # Fixed 10000-49151 scan range for every long-lived listener (never port:0) +│ ├── xprotect-heal.ts # macOS XProtect launch-kill classify + quarantine-clear + bounded reinstall │ ├── socks-bridge.ts # Local 127.0.0.1 SOCKS5 bridge that handles auth handshakes Chromium can't speak │ ├── proxy-config.ts # --proxy URL parsing + cred resolution (URL vs env, fail-fast on both) │ ├── proxy-redact.ts # Cred-redaction helper for any proxy URL surfaced to logs/errors @@ -1281,6 +1307,8 @@ browse/ │ ├── content-security.ts # L1-L3: datamarking, hidden strip, ARIA, URL blocklist, envelopes │ ├── security.ts # L5 canary + L6 verdict combiner + thresholds │ ├── security-classifier.ts # L4 ML classifier (TestSavantAI, runs in the security sidecar) +│ ├── security-sidecar-entry.ts # Sidecar subprocess entrypoint hosting the ONNX classifier +│ ├── security-sidecar-client.ts # server.ts-side client that drives the sidecar │ ├── terminal-agent.ts # Side Panel Claude PTY manager (auth + lifecycle) │ ├── sidebar-utils.ts # Sidebar URL sanitization + helpers │ ├── cookie-import-browser.ts # Decrypt + import cookies from real Chromium browsers diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0bc893e7..0787a6dfef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,197 @@ # Changelog +## [1.67.0.0] - 2026-08-16 + +**The tracker wave: browse survives macOS, installs are complete,** +**memory sync never drops a record. 30 contributors landed.** + +This release mines the full issue tracker and community PR queue. Browse now +classifies a macOS XProtect kill at Chromium launch and heals itself. It +clears the quarantine flag, reinstalls the pinned browser revision from the +right install root, and retries, all bounded and logged. Fresh installs link +every runtime asset a skill references, so /review and friends work on a +clean machine the first time. Brain-sync's queue is drained with a classified +disposition. Privacy-held records are retained and labeled, a failed push +keeps its commit and re-delivers it on the next run, and the retry only ever +publishes commits it authored itself. Twenty-five community PRs landed with +credit, and roughly thirty-five issues close on merge. + +### The numbers that matter + +From the wave's gate eval run (`bun run eval:bg:gate`, log in +`~/.gstack-dev/eval-runs/`) and the free suite (`bun run test`) at HEAD. + +| Metric | Before | After | Δ | +|---|---|---|---| +| Browse launch on macOS 26 (XProtect kill) | manual reinstall | classified + self-healed | automatic | +| Skill runtime assets on a fresh install | SKILL.md + sections only | every referenced asset | /review works day one | +| Brain-sync queue at a push failure | truncated | retained + re-delivered | no data loss | +| Detector push with an interleaved user commit | published it | refuses | author boundary holds | +| Gate evals | 41/43 | 43/43 | both reds root-caused | +| Free suite | — | ~7,000 tests, ~90-100s | green at HEAD | + +The brain-sync row is the one to internalize: the queue is only ever rewritten +by subtracting the exact records that were staged, against a live re-read, so +a record enqueued mid-drain survives to the next boundary. + +### What this means for gstack users + +Upgrade and the three most-reported failure classes disappear: browse comes +back on macOS without touching a terminal, a teammate's first `./setup` +produces working skills, and your cross-machine memory stops silently thinning +under flaky networks. If you filed one of the ~35 issues this closes, your +repro is now a regression test with your name on the commit. + +### Itemized changes + +#### Fixed — the three P0s + +- **Browse dead on macOS (#2554).** Playwright pinned to 1.62.1 (split from + dependabot #2582), plus an XProtect kill-signature classifier with positive + AND negative fixtures, a one-shot quarantine-clear + bounded (~120s, + process-group-killed) reinstall from the gstack install root that pins the + matching Chromium revision, structured heal logging, and an upgrade-time + quarantine-clear + reinstall in `setup` for already-poisoned caches. The + heal resolves the install + root via `os.homedir()` and keeps its manual-remediation guidance even when + the post-heal retry fails. +- **Fresh installs missing runtime assets (#2317, #2454).** `setup` links + every runtime asset with an explicit exclusion list (node_modules, dist, + *.tmpl, test, hidden), pinned by a two-class referenced-paths test: + alias-relative references must exist under the installed alias, repo-anchored + ones in the tree modulo a reasoned dist/ allowlist. +- **Brain-sync data loss (#2549).** Queue records are classified at drain + time: skip-filtered and nonexistent drop WITH counts (full paths in a 0600 + sidecar), privacy-held records are retained and labeled instead of being + wiped as "no allowlisted changes", unparseable lines are preserved, and the + rewrite subtracts the staged set from a LIVE re-read so concurrent enqueues + survive. A failed push keeps its commit; a run-start detector re-delivers it + — receipted, locked, throttled to one attempt per 10 minutes, bounded by + git's low-speed limits (portable to stock macOS), and gated to fire only + when EVERY unpushed commit is its own, so an interleaved manual commit in + ~/.gstack is never auto-published. The sync lock releases on every exit + path, including interrupts mid-push. + +#### Fixed — browse & daemon lifecycle + +- A healthy daemon is never killed by `browse start` (the #2219 iron rule): + a total-budget health probe answers in ~8s, busy daemons get "retry or + --force-restart" plus a nonzero exit, and only an explicit `--force-restart` + ever kills an alive pid — pinned by a regression test. `browse stop` on a + dead daemon short-circuits to success (#2254); `/gstack-upgrade` defers to a + busy daemon and prints the escape hatch (#2551). +- Chromium no longer dies with the terminal: signal handling moved off + Playwright's defaults at all three launch sites with a SIGHUP handler + routing through the real shutdown path, and a tripwire pinning the count. +- The terminal-agent allocates from the same fixed port range as the daemon + (#2314) — and that range now ends at 49151, actually below the macOS + ephemeral pool it exists to avoid; boot retries a raced bind instead of + dying. Windows terminal-agent leaks fixed via `process.kill(pid, 0)` + liveness (#1952) and the error-handling helpers. Contributed by @SYKhayyat + (#2414). +- Daemon crash logs persist without tokens or unsanitized page content + (needle-tested). Contributed by @phuttimatebenchanakatkul (#2461). +- The dead security-shield surface was removed end to end (−272 net lines) while + the live L4 sidecar path keeps its status endpoint — docs updated in the + same commit. Contributed by @frederik-kaster-noygear (#2557, with the + pipe-capture core from #2559). CDP `Emulation.setEmulatedMedia` joins the + allowlist — contributed by @meshailabs (#2419). Windows gbrain probe + timeout — contributed by @vaston-viji (#2450). `browse/dist` mkdir — + contributed by @guyua9 (#2542). +- First `patchedDependencies` entry: playwright-core's two Windows spawn + sites carry `windowsHide` (#2160, #1989), statically pinned and + independently revertable. + +#### Fixed — install & setup correctness + +- Root-alias skills install as rewritten copies, never symlinks whose edits + would corrupt generated sources (#2511, #2201). Windows re-runs refresh + real-directory installs (#2444), and uninstall deletes only directories + that pass BOTH the inventory match and the generated-banner provenance gate, + listing (never deleting) anything else (#2563). +- `--host cursor` gets the full install slice — contributed by @szsunyuan + (#2547). Settings-hook dedup includes the command (#2382) — contributed by + @gregario (#2431). `:user` renders route through `--out-dir` (#2569) with a + migration that cleans legacy in-place render dirt. setup-gbrain invocation + paths fixed (#2250) — contributed by @SomSamantray (#2409). Office-hours + installs into codex/factory/opencode runtime roots (#2449). +- The redact pre-push hook stays opt-in but its fail-open gaps are closed, + with a one-time consent prompt (#1946). Skills-timeline Stop hook ships + fail-open (always exit 0, 2s budget) with setup registration (#2553). +- iOS QA: DebugBridgeTouch compiles out of Release builds — contributed by + @Bastea (#2585); front-most bridge ordering — contributed by @IDSTUK + (#2397); compat preflight docs — contributed by @itstimwhite (#2581). + +#### Fixed — memory & gbrain + +- Windows slug resolution and the decisions.jsonl allowlist (#2396) — + contributed by @source-utsho (#2561). Brain-sync arithmetic-injection + guard — contributed by @sneakygriff (#2588). Windows bash routing for + brain-sync/gbrain — contributed by @ShahriarLak (#2510), extended to every + gbrain-sources spawn (#2471). `--full` walks the full tree — contributed by + @ShahriarLak (#2406). Honest "missing" from brain-cache — contributed by + @sneakygriff (#2587). Memory-ingest parses both codex rollout shapes and + stages outside GSTACK_HOME (#2105, #2104). +- gbrain detection: engine-locked is a healthy status (#2456), bearer-token + thin clients are recognized (#2520), GBRAIN_HOME gets its .gbrain segment + (#2521), project-scoped MCP registrations are honored (#2499). Source pins + respected — contributed by @exGeni (#2417); `--dry-run` works offline + (#2536) — contributed by @CarringtonCreative (#2540); bun-on-npm PATH + guidance (#2487); dream-stage classifier anchored (#2341). + +#### Fixed — version tooling, diff-scope, redaction + +- VERSION stays the 4-digit source of truth; package.json carries the + npm-valid 3-digit translation, lockfiles sync only when they already exist, + and drift is judged on translated forms. Built on re-derived work + contributed by @YiftahR (#2501), @ortonom (#2568), and + @CarringtonCreative (#2531, #2545). Pinned repos compare base and current + against the SAME file (#2462). JSON version-paths get honest per-file + recovery messages. The path pins (`.gstack/version-path`, + `.gstack/package-json-path`) cannot escape the repository — absolute paths, + `..` traversal, and symlink escapes are all refused, and a lockfile + symlinked outside the repo is skipped with a warning. +- Diff-scope covers api/*, migrations/*, and db/data, with a no-match exit + code and uncommitted-work handling (#2526, #2455, #2299). Redact scans the + merge-base range and knows parcel IDs are not phone numbers — contributed + by @Two-Six-Alpha-1115 (#2592, #2591); rebased force-pushes are scanned + correctly, proven by test (#2573). +- The codex model probe caches its verdicts both ways: a working model for an + hour, a deterministic model-400 for 15 minutes (editing config.toml + re-probes immediately) — so the affected account stops paying a 30s round + trip per review section (#2477). Its timeout wrapper now enforces the + deadline with a bash-native watchdog on stock macOS, where no timeout + binary exists. + +#### Fixed — templates & everything else + +- Skills running under Codex skip the nested codex specialist with a printed + notice (#2519). Codex web-search flag unified behind one resolver constant + across 19 sites (#2525). Slugs are sanitized in every path position + (#2550) — with groundwork contributed by @harjothkhara (#1851). AGENTS.md + routing probe — contributed by @gamerey43 (#2500); empty-find fallthrough + killed — contributed by @tranthanhnhatkhoa (#2483); cygpath MSYS builds — + contributed by @chiragborse1 (#2452). /ship's review army loops until clean + (#2391). Question-registry path is absolute (#2489). Retro glob (#2552), + capability-check temp file (#2503), repo-mode stat order (#2195), hover doc + note (#2445), make-pdf boolean flags — including `--strict` and + `--confidential` — no longer swallow the input file, with a guard test that + derives the flag set from the source (#2514). + +#### For contributors + +- Test/generator infra hardened first: host-config golden isolation (#2532), + hermetic-wiring tripwire and YAML ellipsis quoting — contributed by + @sneakygriff (#2586, #2589); prepush PATH separator — contributed by + @luckywenapere (#2544); gen-skill-docs throws on duplicate preamble tokens. +- Dependency hygiene: puppeteer-core removed outright (zero consumers), + adm-zip CVE closed via lock override — contributed by @anupamme (#2485); + transformers/marked/socks bumped with the ONNX sidecar smoke green; + .gitattributes LF pin — contributed by @mlaniak (#2527); GitHub Actions + bumps — contributed by @dependabot (#2594). +- The wave's own adversarial reviews (Codex + Claude, 28 findings) landed as + fixes in-branch; verified residuals are filed in TODOS.md with rationale. + ## [1.66.1.0] - 2026-08-16 **Every claim gstack makes now binds to the content it was made on.** diff --git a/CLAUDE.md b/CLAUDE.md index 316f60bccb..cd76565aa6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,11 +166,12 @@ gstack/ │ ├── test/ # Integration tests │ └── dist/ # Compiled binary ├── extension/ # Chrome extension (side panel + activity feed + CSS inspector) -├── lib/ # Shared libraries (worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, code-intelligence/) +├── lib/ # Shared libraries (worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, version-source.ts, code-intelligence/) +├── patches/ # bun `patchedDependencies` patches (playwright-core windowsHide) ├── docs/designs/ # Design documents ├── setup-deploy/ # /setup-deploy skill (one-time deploy config) ├── .github/ # CI workflows + Docker image -│ ├── workflows/ # evals.yml (E2E on Ubicloud), quality-gate.yml (secret scan), dependency-review.yml, osv-scanner.yml, skill-docs.yml, actionlint.yml, and 7 more (windows, periodic evals, release gates, ci-image) +│ ├── workflows/ # evals.yml (E2E on Ubicloud), quality-gate.yml (secret scan), dependency-review.yml, osv-scanner.yml, skill-docs.yml, actionlint.yml, and 8 more (windows, periodic evals, release gates, ci-image) │ └── docker/ # Dockerfile.ci (pre-baked toolchain + Playwright/Chromium) ├── contrib/ # Contributor-only tools (never installed for users) │ └── add-host/ # /gstack-contrib-add-host skill @@ -429,9 +430,16 @@ leak always BLOCKs (deterministic). - Classifier model cache: `~/.gstack/models/testsavant-small/` (112MB, first run only) - Attack log: `~/.gstack/security/attempts.jsonl` — written by `tunnel-denial-log.ts` (tunnel-surface rejections; rotates at 10MB, 5 generations) -- Session state: `~/.gstack/security/session-state.json` (cross-process, atomic; - NOTE: classifierStatus currently has no live writer — shield status derives - from what's on disk) + +History note (#2557): the cross-process session state +(`~/.gstack/security/session-state.json`), `getStatus()`, the `/health` +`security` field, and the sidepanel SEC shield were all removed — the state +file lost its only writer when sidebar-agent.ts was ripped, so the shield +reported a permanent 'inactive' or a stale false-green 'protected' from +leftover disk state. The live defenses (L1-L3 filters, L4 sidecar on the +inject-scan path) report through their own call sites, never through +/health. `browse/test/server-security-surface.test.ts` pins both the +removal and the live L4 wiring. Do not re-document these as live. ## Dev symlink awareness @@ -448,8 +456,11 @@ symlink or a real copy. If it's a symlink to your working directory, be aware th global install at `~/.claude/skills/gstack/` is used instead **Prefix setting:** Setup creates real directories (not symlinks) at the top level -with a SKILL.md symlink inside (e.g., `qa/SKILL.md -> gstack/qa/SKILL.md`). This -ensures Claude discovers them as top-level skills, not nested under `gstack/`. +with a SKILL.md symlink inside (e.g., `qa/SKILL.md -> gstack/qa/SKILL.md`), plus +links to each skill's runtime assets (sections/, templates, checklists — everything +except SKILL.md, tests, build output, and `.tmpl` sources). Alias skills +(`_gstack-command`, `connect-chrome`) install as rewritten copies, never symlinks. +This ensures Claude discovers them as top-level skills, not nested under `gstack/`. Names are either short (`qa`) or namespaced (`gstack-qa`), controlled by `skill_prefix` in `~/.gstack/config.yaml`. Pass `--no-prefix` or `--prefix` to skip the interactive prompt. @@ -653,6 +664,17 @@ claims v1.7.0.0 as a MINOR and branch B is also a MINOR, B lands at v1.8.0.0 `bin/gstack-next-version` advances within the chosen bump level rather than repicking the level when collisions happen. +**package.json carries the npm-valid translation, not VERSION verbatim.** +VERSION stays the 4-digit source of truth (e.g. `1.67.0.0`); package.json and +any subdirectory manifests with a `version` field get the 3-digit npm-valid +translation (`1.67.0`), and lockfile `version` fields sync only when the +lockfile already exists. `bin/gstack-version-bump` (via `lib/version-source.ts`) +owns the translation and judges drift on translated forms — do NOT "fix" the +apparent mismatch by hand, and do not write a 4-digit version into +package.json (npm rejects it). Rationale and translation rules live in the +`lib/version-source.ts` header; `test/gstack-version-bump.test.ts` pins the +contract. + **Scale-aware bumps — use common sense.** When the diff is big, bump MINOR (or MAJOR), not PATCH. PATCH is for bug fixes and small additions; MINOR is for substantial new capability or substantial reduction; MAJOR is for breaking diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7da57eea3..d69b538fc5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,8 +42,10 @@ No setup needed. Learnings are logged automatically. View them with `/learn`. ln -sfn /path/to/your/gstack-fork .claude/skills/gstack cd .claude/skills/gstack && bun install && bun run build && ./setup ``` - Setup creates per-skill directories with SKILL.md symlinks inside (`qa/SKILL.md -> gstack/qa/SKILL.md`) - and asks your prefix preference. Pass `--no-prefix` to skip the prompt and use short names. + Setup creates per-skill directories with SKILL.md symlinks inside (`qa/SKILL.md -> gstack/qa/SKILL.md`), + links each skill's runtime assets alongside (sections/, templates, checklists — everything except + SKILL.md, tests, build output, and `.tmpl` sources), and asks your prefix preference. + Pass `--no-prefix` to skip the prompt and use short names. 5. **Fix the issue** — your changes are live immediately in this project 6. **Test by actually using gstack** — do the thing that annoyed you, verify it's fixed 7. **Open a PR from your fork** @@ -82,7 +84,10 @@ gstack/ <- your working tree ``` Setup creates real directories (not symlinks) at the top level with a SKILL.md -symlink inside. This ensures Claude discovers them as top-level skills, not nested +symlink inside, plus links to each skill's runtime assets (sections/, templates, +checklists). Alias skills (`_gstack-command`, `connect-chrome`) install as +rewritten copies, never symlinks — editing a symlinked alias would corrupt the +generated source. This ensures Claude discovers them as top-level skills, not nested under `gstack/`. Names depend on your prefix setting (`~/.gstack/config.yaml`). Short names (`/review`, `/ship`) are the default. Run `./setup --prefix` if you prefer namespaced names (`/gstack-review`, `/gstack-ship`). @@ -118,9 +123,11 @@ passes `GSTACK_SKIP_GBRAIN_REGEN=1` inline to the nested `./setup` (so it never dirties tracked source) and runs `gen:skill-docs:user --out-dir .claude/gstack-rendered`, which rewrites only the section-base paths to point at the render. `bin/dev-teardown` removes the render. To make the blocks live across your *other* projects' Claude -sessions, run `gstack-config gbrain-refresh`, which renders them into the global -install (`~/.claude/skills/gstack`), guarded so it never touches a symlinked or -non-gstack directory. +sessions, run `gstack-config gbrain-refresh`, which renders them to a user render +dir (`${GSTACK_USER_RENDER_DIR:-~/.gstack/render/claude}`, swapped in only on a +successful render) and repoints the installed skills at it via `gstack-relink` — +the global install checkout stays git-clean, and the refresh is guarded so it +never touches a symlinked or non-gstack directory. ## Testing & evals diff --git a/README.md b/README.md index 26ad1bfbb2..e2cdf193f5 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,13 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that | `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. | | `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. | +`./setup` also registers one default-on Stop hook in `~/.claude/settings.json`: +`gstack-timeline-stop` (closes dangling session-timeline entries when a session +is interrupted; fail-open — 2s internal budget, always exits 0, can never block +a session). Skip it with `./setup --no-team`, remove it with +`gstack-settings-hook remove-source --source gstack-timeline-stop`; +`gstack-uninstall` removes it too. + ### Continuous checkpoint mode (opt-in, local by default) Set `gstack-config set checkpoint_mode continuous` and skills auto-commit your work as you go with a `WIP:` prefix plus a structured `[gstack-context]` body (decisions, remaining work, failed approaches). Survives crashes and context switches. `/context-restore` reads those commits to reconstruct session state. `/ship` filter-squashes WIP commits before the PR (preserving non-WIP commits) so bisect stays clean. Push is opt-in via `checkpoint_push=true` — default is local-only so you don't trigger CI on every WIP commit. @@ -297,7 +304,7 @@ gstack works well with one sprint. It gets interesting with ten running at once. **Personal automation.** The sidebar agent isn't just for dev workflows. Example: "Browse my kid's school parent portal and add all the other parents' names, phone numbers, and photos to my Google Contacts." Two ways to get authenticated: (1) log in once in the headed browser, your session persists, or (2) click the "cookies" button in the sidebar footer to import cookies from your real Chrome. Once authenticated, Claude navigates the directory, extracts the data, and creates the contacts. -**Prompt injection defense.** Hostile web pages try to hijack your sidebar agent. gstack ships a layered defense: a 22MB ML classifier bundled with the browser scans every page and tool output locally, a Claude Haiku transcript check votes on the full conversation shape, a random canary token in the system prompt catches session exfil attempts across text, tool args, URLs, and file writes, and a verdict combiner requires two classifiers to agree before blocking (prevents single-model false positives on Stack Overflow-style instruction pages). A shield icon in the sidebar header shows status (green/amber/red). Opt in to a 721MB DeBERTa-v3 ensemble via `GSTACK_SECURITY_ENSEMBLE=deberta` for 2-of-3 agreement. Emergency kill switch: `GSTACK_SECURITY_OFF=1`. See [ARCHITECTURE.md](ARCHITECTURE.md#prompt-injection-defense-sidebar-agent) for the full stack. +**Prompt injection defense.** Hostile web pages try to hijack your sidebar agent. gstack ships a layered defense: content filters (datamarking, hidden-element stripping, ARIA scrubbing, URL blocklist) on every page read, plus a 22MB ML classifier running locally in a sidecar subprocess that scans page-derived content before the agent sees it, with a verdict combiner that requires classifier agreement before blocking (prevents single-model false positives on Stack Overflow-style instruction pages). Everything runs on your machine, no network calls. Emergency kill switch: `GSTACK_SECURITY_OFF=1`. See [ARCHITECTURE.md](ARCHITECTURE.md#prompt-injection-defense-sidebar-agent) for the full stack. **Browser handoff when the AI gets stuck.** Hit a CAPTCHA, auth wall, or MFA prompt? `$B handoff` opens a visible Chrome at the exact same page with all your cookies and tabs intact. Solve the problem, tell Claude you're done, `$B resume` picks up right where it left off. The agent even suggests it automatically after 3 consecutive failures. @@ -344,6 +351,7 @@ If you don't have the repo cloned (e.g. you installed via a Claude Code paste an pkill -f "gstack.*browse" 2>/dev/null || true # 2. Remove per-skill directories whose SKILL.md points into gstack/ +# (rm -rf, not rmdir — installed dirs also contain runtime-asset links) find ~/.claude/skills -mindepth 1 -maxdepth 1 -type d ! -name gstack 2>/dev/null | while IFS= read -r dir; do link="$dir/SKILL.md" @@ -351,11 +359,12 @@ while IFS= read -r dir; do target=$(readlink "$link" 2>/dev/null) || continue case "$target" in gstack/*|*/gstack/*) - rm -f "$link" - rmdir "$dir" 2>/dev/null || true + rm -rf "$dir" ;; esac done +# Alias skills install as copies (no symlink to detect) — remove by name +rm -rf ~/.claude/skills/_gstack-command ~/.claude/skills/connect-chrome 2>/dev/null # 3. Remove gstack rm -rf ~/.claude/skills/gstack @@ -368,6 +377,8 @@ rm -rf ~/.codex/skills/gstack* 2>/dev/null rm -rf ~/.factory/skills/gstack* 2>/dev/null rm -rf ~/.kiro/skills/gstack* 2>/dev/null rm -rf ~/.openclaw/skills/gstack* 2>/dev/null +rm -rf ~/.cursor/skills/gstack* 2>/dev/null +rm -rf ~/.config/opencode/skills/gstack* 2>/dev/null # 6. Remove temp files rm -f /tmp/gstack-* 2>/dev/null @@ -377,6 +388,10 @@ rm -rf .gstack .gstack-worktrees .claude/skills/gstack 2>/dev/null rm -rf .agents/skills/gstack* .factory/skills/gstack* 2>/dev/null ``` +Manual removal leaves the gstack Stop hook entry behind in `~/.claude/settings.json` +(the uninstall script removes it for you). Edit that file and delete the hook whose +command path ends in `hosts/claude/hooks/timeline-stop-hook`. + ### Clean up CLAUDE.md The uninstall script does not edit CLAUDE.md. In each project where gstack was added, remove the `## gstack` and `## Skill routing` sections. diff --git a/SKILL.md b/SKILL.md index b7f1705676..9069bbb1b0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -106,9 +106,11 @@ else fi ~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"gstack","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null & _HAS_ROUTING="no" -if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then - _HAS_ROUTING="yes" -fi +for _RF in CLAUDE.md AGENTS.md; do + if [ -f "$_RF" ] && grep -q "## Skill routing" "$_RF" 2>/dev/null; then + _HAS_ROUTING="yes" + fi +done _ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false") echo "HAS_ROUTING: $_HAS_ROUTING" echo "ROUTING_DECLINED: $_ROUTING_DECLINED" @@ -377,10 +379,13 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e # Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is # a no-op in remote mode; the brain server pulls from GitHub/GitLab on its # own cadence. Read claude.json directly to keep this preamble fast (no -# subprocess to claude CLI on every skill start). +# subprocess to claude CLI on every skill start). Both registration scopes +# are read (#2499): user scope, then the nearest-ancestor project scope. _GBRAIN_MCP_MODE="none" +_GBRAIN_MCP_ENTRY="" if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then - _GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null) + _GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null) + _GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null) case "$_GBRAIN_MCP_TYPE" in url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;; stdio) _GBRAIN_MCP_MODE="local-stdio" ;; @@ -401,6 +406,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -414,7 +420,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(printf '%s' "${_GBRAIN_MCP_ENTRY:-}" | jq -r '.url // empty' 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/TODOS.md b/TODOS.md index a081bb1a35..38f69c9dec 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,6 +2,166 @@ ## NEXT PRIORITY +### P1: ZeroEntropy sunset — gbrain's default embedding provider dies Sept 4, 2026 (#2365) + +**What:** ZeroEntropy (acquired by Notion) shuts down September 4, 2026. gbrain's +default embedding provider needs a migration path before then; gstack's +setup-gbrain flow should stop recommending it and detect/warn existing installs. + +**Why:** Hard external deadline. After Sept 4, fresh setup-gbrain runs against the +default provider fail, and existing brains stop embedding new pages silently. + +**Effort:** M (human ~2d, CC ~1h — mostly gbrain-side; gstack side is detect+warn). +**Priority:** P1 (calendar-driven). **Depends on:** gbrain upstream provider support. + +### P2: v1.67 fix-wave deferrals — next-wave queue + +Filed at v1.67.0.0 implementation time (see the wave plan's "Cut from this +wave"). Each was explicitly deferred with rationale, not dropped: + +- **#2522 Windows omnibus mining** — the targeted Windows fixes landed in + v1.67 (#2414/#2510/#2561/#2542/#2452-half); the omnibus PR still carries a + doctor/migration surface worth extracting. Effort M→S with CC. +- **#2443 AskUserQuestion numbering redesign** — real mismatch (brief letters + vs host-rendered numbers), but a prompt-behavior redesign that shifts eval + baselines; needs its own PR with baseline refresh. Effort S. +- **#2447 typecheck infra** — tsconfig + repo-wide typecheck script + latent + type fixes. High-value, repo-wide blast radius, own PR with bake time. + Effort M. Re-derive on current main (several of its fixes landed since). +- **#2492 per-project Chromium profile** — needs an on-disk migration story + for the machine-wide profile default and SingletonLock scoping. Effort M. +- **#2286 `triggers:` frontmatter** — the Claude Code router never reads the + key; folding voice-triggers into description costs catalog tokens. Needs a + maintainer token-budget decision (catalog cap is enforced). Effort S. +- **#2378 release-tag upgrade semantics** — update-check gates on + main:VERSION while upgrade installs main HEAD; installs sit between + releases. Design decision: tag-pinned installs vs HEAD. Effort M. +- **Feature-PR triage queue** — #2564 (/deck), #2497 (browse record — best of + the batch), #2476 (a11y review, unblocked by the CDP media-emulation entry + landed in v1.67), #2446 (Cua), #2448 (tiered outside voice), #2412 (lens + layer), #2241 (/grok), #2507 (pi host), #2298 (Kimi host), #2438+#2436 + (gbrain doc-sync pair, ordered), #2442 (portable skill roots), #2534 + (gbrain MCP routing), #2535 (outside voice for /investigate,/cso,/devex), + #2576 (fast-ship rework — re-evaluate against v1.66's CI speedup), + #2580 (land-and-deploy CI tiers — human-gate UX needs maintainer call). + +### P2: v1.67 adversarial-review residuals (verified, deferred with rationale) + +Filed at v1.67 ship time from the Codex + Claude adversarial passes. Each was +verified real but needs design input or device access the wave lacked: + +- **brain-sync enqueue lock** — the drain's surgical rewrite closes the reader + side, but a lockless producer appending between the live re-read and the + tmp+mv can still orphan one record. Needs a shared enqueue/drain lock + (mkdir-style, like the drain's). Effort S. +- **iOS tap routing across windows** — Bridges template's frontmostWindow can + swallow taps when a keyboard/menu/transparent overlay window is topmost but + doesn't handle the coordinate. Needs hit-test-aware routing + real-device + verification. Effort M. (Related: the multi-window rewrite has no static + pins — see the test-gap backlog below.) +- **pair-agent implicit --force-restart** — pair-agent auto-kills a healthy + headless daemon (tabs/cookies) with no consent, contradicting the #2219 + iron rule it now sits beside. Needs a consent prompt or explicit-flag + requirement; UX call. Effort S. +- **bin-context slugFromEnvironment walk-up parity (win32)** — the native + fallback slugs the INNERMOST repo while bash gstack-slug walks to the + outermost canonical remote; nested/vendored repos split stores. Effort S. +- **hasRemoteOnlyGbrainMcp is machine-global** — one project's remote gbrain + registration reclassifies broken local engines as thin-client everywhere; + also confirm Claude Code's user-vs-project MCP precedence against + brain-cache's user-first assumption. Effort S. +- **next-version git-fallback breadth** — the degraded path counts every + remote-tracking ref on every remote (stale experiment branches inflate the + allocation) and a failed 3-digit base read flips width to 4. Warned today; + tighten to origin + width-pin. Effort S. +- **Stop-hook registration pins the setup-time absolute path** — registering + from a dev worktree bakes that path into settings.json; deleting the + worktree leaves a dead hook erroring on every session stop until removed. + Register the global-install path or re-point on upgrade. Effort S. +- **Accepted threat-model notes (documented, no action planned):** + redact-prepush treats content pushed to ANY private remote as already-left + (accident-only threat model); a parcel-shaped twin within 400 chars can + suppress phone redaction (WARN-tier pattern, attacker-influence accepted); + codex-probe's 400-signature grep can misread a transient proxy 400 as + MODEL_UNUSABLE (bounded by the 15-min negative-cache TTL). + +### P2: v1.67 coverage-audit test-gap backlog (5-agent sweep, ranked) + +The wave's Step-7 coverage audit (5 subsystem agents, ~700 changed paths, +~84% covered) ranked these residual gaps. None block v1.67 (the behaviors +shipped verified by hand or adjacent tests); each is a cheap pin against +silent regression: + +- **setup Playwright bootstrap block** — `_clear_playwright_quarantine`, + `_PW_LOCK` stale-holder reclaim, `_kill_tree`/`_wait_with_deadline`, Ubuntu + 26.04 platform override: zero test references. The P0 #2554 heal's shell + half. Effort S each. +- **redact-prepush `scanAddedLines` slicing** — the >1MiB catch-up-diff chunk + path (the reason the function exists) is unexercised; a regression + reintroduces blocking-while-unscanned. Effort S. +- **supabase telemetry-ingest edge function** — zero tests; producer caps at + 200 chars vs ingest's 500 (dead server cap); no column↔migration pin. +- **gbrain-repo-policy-client** — no direct test file; the spawn-failed vs + unreadable split (its raison d'être) and win32 bash-wrapping unpinned. +- **extension client half of token bootstrap** — `POST /extension-token` 403 + → disconnected path untested (server half is exhaustively pinned); also + pin manifest `key` ↔ `GSTACK_EXTENSION_ID` via extension-id.ts. Effort S. +- **`assertJsOriginAllowed`** — this wave made the js/eval origin gate + mandatory; the gate itself has zero direct tests. Effort S. +- **`runBoundedChromiumReinstall`** — every heal test stubs it; the 120s + deadline + process-group SIGKILL + spawn-error branch never execute. +- **CI three-way image-tag drift** — ci-image.yml + evals.yml + + evals-periodic.yml each carry the hashFiles tag expression, synced by + comment only. One test reading all three. Effort S. +- **evals.yml matrix census** — the silent-never-ran class (see the two + files this wave had to re-add) has no membership test. +- **design-doc-discovery resolver** — new anti-drift block, zero tests for + the -nt freshness rule or cross-render identity. +- **Bridges.swift multi-window rewrite** — no static pins for + orderedWindows/searchRoots ordering; DebugBridgeTouch's `#if !defined(DEBUG)` + guard and Package.swift's `.define("DEBUG")` have no tripwire (Guideline + 2.5.1 exposure on revert); parity test runs periodic-lane only. +- **Smaller pins:** gstack-egress `sanitizeForDisplay`; freeze-dir tilde + expansion; gstack-config `pair_agent` key + space-bearing values; + session-cookie-store tripwire scope (points at the wrapper, not the + factory); redact-patterns `/^pass(word)?$/i` placeholder loosening + + compact-timestamp negative; fs-atomic adoption tripwire; tracker-guard + `safeSource`; eval-watch `PARTIAL_PATH`; `killProcessGroup`; + make-pdf orchestrator `PAYLOAD_TMP_DIR` + CJK stack + smartypants NUL; + gbrain-guards `gbrainHome()`; gbrain-local-status `"timeout"` exclusion; + meta-commands state-load tripwire re-point; flushBuffers/audit 0600 census; + openclaw `version:` frontmatter drop (pre-wave, main-side — restore + extraFields or record as intentional); terse-build's stale "all 4" set + (main-side 5th terse-gated resolver). + +### P2: v1.67 review-fix-batch deferrals (post-wave review army findings) + +Filed at review-fix-batch time, deferred with rationale: + +- **setup host-function dedup** — four near-verbatim `create_*_runtime_root` + + `link_*_skill_dirs` copies (codex/factory/opencode/cursor) drift + independently (the #2142 ownership gate had to be patched at every site). + Parameterize on host name + skills dir. Effort S with CC. +- **cmd.exe `%VAR%` expansion in gbrainInvocation quoting** — Windows-only, + contrived escalation (requires attacker-controlled env var names), but the + quoting is not cmd.exe-safe. Fix direction: route win32 spawns through + cross-spawn (dependency decision — bun-polyfill.cjs already carries it for + the browse daemon). Effort S. +- **make-pdf flag registry metadata** — commands.ts flags are bare strings; + add a takes-value field and DERIVE cli.ts's BOOLEAN_FLAGS from the + registry (the structural `--no-*` test added in this batch covers only the + negation shape). Effort S. +- **legacy host-glob uninstall provenance gating** — gstack-uninstall's + codex/factory/kiro `gstack*` globs still rm -rf without a provenance + check; bring them to parity with the cursor banner gate added in this + batch (v1.67 added cursor; the legacy three are inherited behavior). + Effort S. +- **cursor auto-detect breadth** — `-d ~/.cursor` triggers a full extra + render + install for every Cursor-having dev on every ./setup (the dir + exists for anyone who ever launched the IDE). Product call on narrowing to + CLI detection (`command -v cursor`) or an opt-in flag. Effort S, needs a + maintainer decision on the detection contract. + ### P2: Persona-fleet hostile-user harness (fork port wave 2 deferral) **What:** Port the methodology behind time-attack/gstack's 87-hostile-user @@ -3050,35 +3210,24 @@ rendering quirks"); or (c) move this test to periodic until (a)/(b) lands. `test/helpers/claude-pty-runner.ts:308` (`isNumberedOptionListVisible`). Evidence: `~/.gstack-dev/eval-runs/pdwu-verify-*.log`. **Effort:** M (human ~half day / CC ~30min). -### P2: Follow-up fix waves from the 2026-08-14 tracker audit (v1.64.0.0) - -The full-tracker audit behind v1.64.0.0 verified every open PR/issue against -main and consciously deferred four coherent fix waves. Audit records: -`~/.gstack/projects/garrytan-gstack/` eng-review artifacts + the v1.64 PR body. - -**Wave A — browse-daemon lifecycle.** Watchdog kills headed handoff sessions -(PRs 2565/2405/2346), macOS headed launch broken by the rebrand-invalidated -Chromium signature + XProtect (issues 2554/2242/2138/1829/1379 — the three -darwin-skipped handoff tests in browse/test/handoff.test.ts un-skip when this -lands), busy-daemon kill (2219/2231), cosmetic SIGTERM ignore (2220), -Playwright pin bump (PR 1761, #1703 — rebuilds the CI browser image). -Start with the signature/re-sign question; everything else is small. - -**Wave B — install integrity.** connect-chrome alias shadowing (PR 2202, -issues 2201/2511), Playwright bootstrap aborts/timeouts (PRs 2233/2359, -issues 1902/2136), --host cursor/slate wiring (PRs 2547/2432, issue 2361), -review checklist/specialists never copied (issues 2317/2518), Windows re-run -refresh (#2444). Blast radius is `setup` — one focused PR. - -**Wave C — gbrain trust boundary.** Transcript trust/scope/source isolation -(PR 2232, issue 2140), brain-sync queue truncation (#2549), worktree source -pins (PR 2417, #2516), thin-client detection gaps (#2520/#2456), plus small -absorbs (2371/2360/2406/2369/2368/2321). Needs never-double-store review. - -**Wave D — ship/version allocator.** Queue-down fallback (PRs 2545/2546), -npm-invalid subdir manifest versions (PR 2531), versionless repos -(2343/2334/2501, #1474), diff-scope specialist routing rewrite -(#2526/#2299/#2455), /review token runaway (#2519). - -**Depends on:** v1.64.0.0 landing. Each wave is one bundled PR per the -fix-wave pattern. +### P3: Residuals from the 2026-08-14 tracker-audit waves (mostly shipped in v1.67.0.0) + +The four deferred waves (A: browse-daemon lifecycle, B: install integrity, +C: gbrain trust boundary, D: ship/version allocator) LANDED in the v1.67.0.0 +fix wave: XProtect self-heal + Playwright bump + busy-daemon iron rule + +signal policy (A); alias shadowing + cursor slice + runtime assets + Windows +refresh (B); brain-sync disposition model + source pins + thin-client +detection (C); version allocator end-state + subdir manifests + diff-scope +globs (D). What remains, re-filed individually: + +- Watchdog kills headed handoff sessions (PRs 2565/2405/2346) and the three + darwin-skipped handoff tests in browse/test/handoff.test.ts — verify + whether the v1.67 XProtect + rebrand work un-blocks them, then un-skip or + fix. Effort S. +- Transcript trust/scope/source isolation (PR 2232, issue 2140) — needs the + never-double-store review. Effort M. +- Versionless-repo onboarding (#1474, issues 2343/2334) — the #2501 JSON + version-path half landed; the no-version-file-at-all flow did not. +- Playwright bootstrap abort/timeout absorbs (PRs 2233/2359, issues + 1902/2136) — partially superseded by v1.67's bounded bootstrap; verify + and close or extract the remainder. diff --git a/VERSION b/VERSION index 4790d6fbd1..20aa59f0d2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.66.1.0 +1.67.0.0 diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index f24a1cdcb2..6324f5e2b9 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -116,9 +116,11 @@ else fi ~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"autoplan","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null & _HAS_ROUTING="no" -if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then - _HAS_ROUTING="yes" -fi +for _RF in CLAUDE.md AGENTS.md; do + if [ -f "$_RF" ] && grep -q "## Skill routing" "$_RF" 2>/dev/null; then + _HAS_ROUTING="yes" + fi +done _ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false") echo "HAS_ROUTING: $_HAS_ROUTING" echo "ROUTING_DECLINED: $_ROUTING_DECLINED" @@ -512,10 +514,13 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e # Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is # a no-op in remote mode; the brain server pulls from GitHub/GitLab on its # own cadence. Read claude.json directly to keep this preamble fast (no -# subprocess to claude CLI on every skill start). +# subprocess to claude CLI on every skill start). Both registration scopes +# are read (#2499): user scope, then the nearest-ancestor project scope. _GBRAIN_MCP_MODE="none" +_GBRAIN_MCP_ENTRY="" if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then - _GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null) + _GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null) + _GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null) case "$_GBRAIN_MCP_TYPE" in url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;; stdio) _GBRAIN_MCP_MODE="local-stdio" ;; @@ -536,6 +541,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -549,7 +555,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(printf '%s' "${_GBRAIN_MCP_ENTRY:-}" | jq -r '.url // empty' 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 @@ -634,8 +640,8 @@ eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" _PROJ="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}" if [ -d "$_PROJ" ]; then echo "--- RECENT ARTIFACTS ---" - find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs ls -t 2>/dev/null | head -3 - [ -f "$_PROJ/${_BRANCH}-reviews.jsonl" ] && echo "REVIEWS: $(wc -l < "$_PROJ/${_BRANCH}-reviews.jsonl" | tr -d ' ') entries" + find "$_PROJ/ceo-plans" "$_PROJ/checkpoints" -type f -name "*.md" 2>/dev/null | xargs -r ls -t 2>/dev/null | head -3 + [ -f "$_PROJ/${BRANCH:-unknown}-reviews.jsonl" ] && echo "REVIEWS: $(wc -l < "$_PROJ/${BRANCH:-unknown}-reviews.jsonl" | tr -d ' ') entries" [ -f "$_PROJ/timeline.jsonl" ] && tail -5 "$_PROJ/timeline.jsonl" if [ -f "$_PROJ/timeline.jsonl" ]; then _LAST=$(grep "\"branch\":\"${_BRANCH}\"" "$_PROJ/timeline.jsonl" 2>/dev/null | grep '"event":"completed"' | tail -1) @@ -643,7 +649,7 @@ if [ -d "$_PROJ" ]; then _RECENT_SKILLS=$(grep "\"branch\":\"${_BRANCH}\"" "$_PROJ/timeline.jsonl" 2>/dev/null | grep '"event":"completed"' | tail -3 | grep -o '"skill":"[^"]*"' | sed 's/"skill":"//;s/"//' | tr '\n' ',') [ -n "$_RECENT_SKILLS" ] && echo "RECENT_PATTERN: $_RECENT_SKILLS" fi - _LATEST_CP=$(find "$_PROJ/checkpoints" -name "*.md" -type f 2>/dev/null | xargs ls -t 2>/dev/null | head -1) + _LATEST_CP=$(find "$_PROJ/checkpoints" -name "*.md" -type f 2>/dev/null | xargs -r ls -t 2>/dev/null | head -1) [ -n "$_LATEST_CP" ] && echo "LATEST_CHECKPOINT: $_LATEST_CP" if [ -f "$_PROJ/decisions.active.json" ]; then echo "--- ACTIVE DECISIONS (recent, scope-relevant) ---" @@ -719,7 +725,7 @@ If you are looping on the same diagnostic, same file, or failed fix variants, ST ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) -Before each AskUserQuestion, choose `question_id` from `scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. +Before each AskUserQuestion, choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `printf '%s' "" | ~/.claude/skills/gstack/bin/gstack-question-preference --check "" --summary-stdin` (piped summary feeds the one-way keyword net, #2024). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. **Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append `` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered `question_id`. @@ -1151,6 +1157,12 @@ elif ! _gstack_codex_auth_probe >/dev/null; then _gstack_codex_log_event "codex_auth_failed" echo "[codex-unavailable: auth missing] — proceeding with Claude subagent only. Run \`codex login\` or set \$CODEX_API_KEY to enable dual-voice review." _CODEX_AVAILABLE=false +# Round-trip model probe (#2477): auth can pass while the account's configured +# model is rejected with an HTTP 400 (stale `model =` pin in ~/.codex/config.toml). +# ~10s on first run, cached 1h; timeouts fail open (probe returns 0). +elif ! _gstack_codex_model_probe; then + echo "[codex-unavailable: configured model rejected] — proceeding with Claude subagent only. Fix the \`model =\` pin in ~/.codex/config.toml (see [notice.model_migrations] there for the replacement)." + _CODEX_AVAILABLE=false else _gstack_codex_version_check # non-blocking warn if known-bad _CODEX_AVAILABLE=true @@ -1195,7 +1207,7 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. What alternatives were dismissed too quickly? What competitive or market risks are unaddressed? What scope decisions will look foolish in 6 months? Be adversarial. No compliments. Just the strategic blind spots. - File: " -C "$_REPO_ROOT" -s read-only --enable web_search_cached < /dev/null + File: " -C "$_REPO_ROOT" -s read-only -c 'web_search="cached"' < /dev/null _CODEX_EXIT=$? if [ "$_CODEX_EXIT" = "124" ]; then _gstack_codex_log_event "codex_timeout" "600" @@ -1318,7 +1330,7 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. accessibility requirements (keyboard nav, contrast, touch targets) specified or aspirational? Does the plan describe specific UI decisions or generic patterns? What design decisions will haunt the implementer if left ambiguous? - Be opinionated. No hedging." -C "$_REPO_ROOT" -s read-only --enable web_search_cached < /dev/null + Be opinionated. No hedging." -C "$_REPO_ROOT" -s read-only -c 'web_search="cached"' < /dev/null _CODEX_EXIT=$? if [ "$_CODEX_EXIT" = "124" ]; then _gstack_codex_log_event "codex_timeout" "600" @@ -1394,7 +1406,7 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. CEO: Design: - File: " -C "$_REPO_ROOT" -s read-only --enable web_search_cached < /dev/null + File: " -C "$_REPO_ROOT" -s read-only -c 'web_search="cached"' < /dev/null _CODEX_EXIT=$? if [ "$_CODEX_EXIT" = "124" ]; then _gstack_codex_log_event "codex_timeout" "600" @@ -1520,7 +1532,7 @@ Log: "Phase 3.5 skipped — no developer-facing scope detected." 3. API/CLI design: are names guessable? Are defaults sensible? Is it consistent? 4. Docs: can a dev find what they need in under 2 minutes? Are examples copy-paste-complete? 5. Upgrade path: can devs upgrade without fear? Migration guides? Deprecation warnings? - Be adversarial. Think like a developer who is evaluating this against 3 competitors." -C "$_REPO_ROOT" -s read-only --enable web_search_cached < /dev/null + Be adversarial. Think like a developer who is evaluating this against 3 competitors." -C "$_REPO_ROOT" -s read-only -c 'web_search="cached"' < /dev/null _CODEX_EXIT=$? if [ "$_CODEX_EXIT" = "124" ]; then _gstack_codex_log_event "codex_timeout" "600" diff --git a/autoplan/SKILL.md.tmpl b/autoplan/SKILL.md.tmpl index 011bbacddd..08d7933e9c 100644 --- a/autoplan/SKILL.md.tmpl +++ b/autoplan/SKILL.md.tmpl @@ -262,6 +262,12 @@ elif ! _gstack_codex_auth_probe >/dev/null; then _gstack_codex_log_event "codex_auth_failed" echo "[codex-unavailable: auth missing] — proceeding with Claude subagent only. Run \`codex login\` or set \$CODEX_API_KEY to enable dual-voice review." _CODEX_AVAILABLE=false +# Round-trip model probe (#2477): auth can pass while the account's configured +# model is rejected with an HTTP 400 (stale `model =` pin in ~/.codex/config.toml). +# ~10s on first run, cached 1h; timeouts fail open (probe returns 0). +elif ! _gstack_codex_model_probe; then + echo "[codex-unavailable: configured model rejected] — proceeding with Claude subagent only. Fix the \`model =\` pin in ~/.codex/config.toml (see [notice.model_migrations] there for the replacement)." + _CODEX_AVAILABLE=false else _gstack_codex_version_check # non-blocking warn if known-bad _CODEX_AVAILABLE=true @@ -306,7 +312,7 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. What alternatives were dismissed too quickly? What competitive or market risks are unaddressed? What scope decisions will look foolish in 6 months? Be adversarial. No compliments. Just the strategic blind spots. - File: " -C "$_REPO_ROOT" -s read-only --enable web_search_cached < /dev/null + File: " -C "$_REPO_ROOT" -s read-only {{CODEX_WEB_SEARCH_FLAG}} < /dev/null _CODEX_EXIT=$? if [ "$_CODEX_EXIT" = "124" ]; then _gstack_codex_log_event "codex_timeout" "600" @@ -429,7 +435,7 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. accessibility requirements (keyboard nav, contrast, touch targets) specified or aspirational? Does the plan describe specific UI decisions or generic patterns? What design decisions will haunt the implementer if left ambiguous? - Be opinionated. No hedging." -C "$_REPO_ROOT" -s read-only --enable web_search_cached < /dev/null + Be opinionated. No hedging." -C "$_REPO_ROOT" -s read-only {{CODEX_WEB_SEARCH_FLAG}} < /dev/null _CODEX_EXIT=$? if [ "$_CODEX_EXIT" = "124" ]; then _gstack_codex_log_event "codex_timeout" "600" @@ -505,7 +511,7 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. CEO: Design: - File: " -C "$_REPO_ROOT" -s read-only --enable web_search_cached < /dev/null + File: " -C "$_REPO_ROOT" -s read-only {{CODEX_WEB_SEARCH_FLAG}} < /dev/null _CODEX_EXIT=$? if [ "$_CODEX_EXIT" = "124" ]; then _gstack_codex_log_event "codex_timeout" "600" @@ -631,7 +637,7 @@ Log: "Phase 3.5 skipped — no developer-facing scope detected." 3. API/CLI design: are names guessable? Are defaults sensible? Is it consistent? 4. Docs: can a dev find what they need in under 2 minutes? Are examples copy-paste-complete? 5. Upgrade path: can devs upgrade without fear? Migration guides? Deprecation warnings? - Be adversarial. Think like a developer who is evaluating this against 3 competitors." -C "$_REPO_ROOT" -s read-only --enable web_search_cached < /dev/null + Be adversarial. Think like a developer who is evaluating this against 3 competitors." -C "$_REPO_ROOT" -s read-only {{CODEX_WEB_SEARCH_FLAG}} < /dev/null _CODEX_EXIT=$? if [ "$_CODEX_EXIT" = "124" ]; then _gstack_codex_log_event "codex_timeout" "600" diff --git a/benchmark-models/SKILL.md b/benchmark-models/SKILL.md index 9519c73f69..80d32a6b7b 100644 --- a/benchmark-models/SKILL.md +++ b/benchmark-models/SKILL.md @@ -110,9 +110,11 @@ else fi ~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"benchmark-models","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null & _HAS_ROUTING="no" -if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then - _HAS_ROUTING="yes" -fi +for _RF in CLAUDE.md AGENTS.md; do + if [ -f "$_RF" ] && grep -q "## Skill routing" "$_RF" 2>/dev/null; then + _HAS_ROUTING="yes" + fi +done _ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false") echo "HAS_ROUTING: $_HAS_ROUTING" echo "ROUTING_DECLINED: $_ROUTING_DECLINED" @@ -381,10 +383,13 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e # Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is # a no-op in remote mode; the brain server pulls from GitHub/GitLab on its # own cadence. Read claude.json directly to keep this preamble fast (no -# subprocess to claude CLI on every skill start). +# subprocess to claude CLI on every skill start). Both registration scopes +# are read (#2499): user scope, then the nearest-ancestor project scope. _GBRAIN_MCP_MODE="none" +_GBRAIN_MCP_ENTRY="" if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then - _GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null) + _GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null) + _GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null) case "$_GBRAIN_MCP_TYPE" in url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;; stdio) _GBRAIN_MCP_MODE="local-stdio" ;; @@ -405,6 +410,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -418,7 +424,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(printf '%s' "${_GBRAIN_MCP_ENTRY:-}" | jq -r '.url // empty' 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/benchmark/SKILL.md b/benchmark/SKILL.md index f0528c08f4..8b1176b245 100644 --- a/benchmark/SKILL.md +++ b/benchmark/SKILL.md @@ -110,9 +110,11 @@ else fi ~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"benchmark","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null & _HAS_ROUTING="no" -if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then - _HAS_ROUTING="yes" -fi +for _RF in CLAUDE.md AGENTS.md; do + if [ -f "$_RF" ] && grep -q "## Skill routing" "$_RF" 2>/dev/null; then + _HAS_ROUTING="yes" + fi +done _ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false") echo "HAS_ROUTING: $_HAS_ROUTING" echo "ROUTING_DECLINED: $_ROUTING_DECLINED" @@ -381,10 +383,13 @@ _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || e # Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is # a no-op in remote mode; the brain server pulls from GitHub/GitLab on its # own cadence. Read claude.json directly to keep this preamble fast (no -# subprocess to claude CLI on every skill start). +# subprocess to claude CLI on every skill start). Both registration scopes +# are read (#2499): user scope, then the nearest-ancestor project scope. _GBRAIN_MCP_MODE="none" +_GBRAIN_MCP_ENTRY="" if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then - _GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null) + _GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '.mcpServers.gbrain // ((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // empty' "$HOME/.claude.json" 2>/dev/null) + _GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null) case "$_GBRAIN_MCP_TYPE" in url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;; stdio) _GBRAIN_MCP_MODE="local-stdio" ;; @@ -405,6 +410,7 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_DO_PULL=1 if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then _BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0) + case "$_BRAIN_LAST" in ''|*[!0-9]*) _BRAIN_LAST=0 ;; esac _BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST )) [ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0 fi @@ -418,7 +424,7 @@ fi if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then # Remote-MCP mode: local artifacts sync is a no-op (brain admin's server # pulls from GitHub/GitLab). Show the user this is by design, not broken. - _GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|') + _GBRAIN_HOST=$(printf '%s' "${_GBRAIN_MCP_ENTRY:-}" | jq -r '.url // empty' 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|' | head -1 | tr -cd 'A-Za-z0-9._-') echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})" elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then _BRAIN_QUEUE_DEPTH=0 diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index f99c96591b..9691c226ef 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -291,6 +291,14 @@ projects/*/*-design-*.md projects/*/*-test-plan-*.md projects/*/*-eng-review-test-plan-*.md projects/*/timeline.jsonl +# The decision store. gstack-decision-log enqueues projects//decisions.jsonl +# after EVERY write, but no glob above matched it, so compute_paths_to_stage rejected +# all of them at its "must match at least one allowlist glob" check -- a writer +# enqueueing a path the syncer is guaranteed to drop. Without these the durable +# decision ledger never leaves the machine, on any platform. +projects/*/decisions.jsonl +projects/*/decisions.active.json +projects/*/decisions.archive.jsonl retros/*.md developer-profile.json builder-journey.md @@ -318,6 +326,9 @@ cat > "$GSTACK_HOME/.brain-privacy-map.json" <<'EOF' {"pattern": "projects/*/*-design-*.md", "class": "artifact"}, {"pattern": "projects/*/*-test-plan-*.md", "class": "artifact"}, {"pattern": "projects/*/*-eng-review-test-plan-*.md", "class": "artifact"}, + {"pattern": "projects/*/decisions.jsonl", "class": "artifact"}, + {"pattern": "projects/*/decisions.active.json", "class": "artifact"}, + {"pattern": "projects/*/decisions.archive.jsonl", "class": "artifact"}, {"pattern": "retros/*.md", "class": "artifact"}, {"pattern": "builder-journey.md", "class": "artifact"}, {"pattern": "projects/*/timeline.jsonl", "class": "behavioral"}, diff --git a/bin/gstack-brain-cache b/bin/gstack-brain-cache index f7694f33fd..abf45a013f 100755 --- a/bin/gstack-brain-cache +++ b/bin/gstack-brain-cache @@ -126,13 +126,27 @@ function sha8(input: string): string { * Detects the active brain endpoint (MCP URL or 'local') and returns its * stable identity hash. Used to detect when the user switches brains * (different endpoint → different cache). + * + * Reads BOTH registration scopes in ~/.claude.json (#2499): user scope + * (.mcpServers.gbrain) first, then project scope + * (.projects["/abs/path"].mcpServers.gbrain — what `claude mcp add` + * WITHOUT --scope user writes), preferring the nearest ancestor of cwd + * (longest matching project key) so nested repos resolve to their own + * brain. Before the project-scope read, two different project-scoped + * brains both hashed to 'local', so switching between them never + * invalidated the cache — the exact scenario this function exists to + * catch. + * + * Params exist for tests; production callers use the defaults. */ -export function detectEndpointHash(): string { - const claudeJsonPath = join(homedir(), '.claude.json'); +export function detectEndpointHash( + claudeJsonPath: string = join(homedir(), '.claude.json'), + cwd: string = process.cwd(), +): string { if (existsSync(claudeJsonPath)) { try { const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')); - const gbrainServer = cfg?.mcpServers?.gbrain; + const gbrainServer = resolveGbrainMcpEntry(cfg, cwd); const url = gbrainServer?.url || gbrainServer?.transport?.url; if (typeof url === 'string' && url.length > 0) { return sha8(url); @@ -143,6 +157,40 @@ export function detectEndpointHash(): string { return 'local'; } +interface McpEntryish { + url?: unknown; + transport?: { url?: unknown }; +} + +/** + * User-scope gbrain entry, else the nearest-ancestor project-scope entry + * for cwd (#2499). Path-boundary-aware: /a/repo never matches /a/repo2. + * Both separators are accepted so Windows project keys resolve. + */ +function resolveGbrainMcpEntry( + cfg: unknown, + cwd: string, +): McpEntryish | undefined { + const root = cfg as { + mcpServers?: Record; + projects?: Record }>; + } | null; + if (root?.mcpServers?.gbrain) return root.mcpServers.gbrain; + const projects = root?.projects; + if (!projects || typeof projects !== 'object') return undefined; + let best: { key: string; entry: McpEntryish } | undefined; + for (const [key, val] of Object.entries(projects)) { + if (!val || typeof val !== 'object') continue; + const entry = val.mcpServers?.gbrain; + if (!entry || typeof entry !== 'object') continue; + const isAncestor = + cwd === key || cwd.startsWith(`${key}/`) || cwd.startsWith(`${key}\\`); + if (!isAncestor) continue; + if (!best || key.length > best.key.length) best = { key, entry }; + } + return best?.entry; +} + // ────────────────────────────────────────────────────────────────────────── // Atomic write (tmp + rename) // ────────────────────────────────────────────────────────────────────────── @@ -521,6 +569,21 @@ function fetchRecentDecisions(projectSlug: string | null): string | null { '--json', ]); if (!result?.pages) { + // F10 bug fix: this branch used to return the hardcoded + // "_No prior skill runs recorded._" string here, which is indistinguishable + // from a genuine zero-rows result. That silently converted a gbrain- + // unreachable FAILURE into a "successful" cached digest — refreshEntity() + // would write it and stamp last_refresh, so the false negative survived + // every subsequent TTL cycle forever. Returning null instead lets cmdGet's + // existing missing/stale-fallback machinery report the true state, exactly + // like every sibling fetcher (fetchGoals, fetchSimplePage) already does on + // failure. + return null; + } + // A malformed payload ({pages: {}} etc.) must classify as failure, not crash + // refreshEntity mid-refresh — same honest-missing polarity as the F10 fix. + if (!Array.isArray(result.pages)) return null; + if (result.pages.length === 0) { return `# Recent decisions (project: ${projectSlug})\n\n_No prior skill runs recorded._\n`; } const lines = result.pages.map((p) => `- ${p.title || p.slug}`); @@ -576,7 +639,17 @@ function fetchSalience(projectSlug: string | null): string | null { '--limit', '10', '--json', ]); - if (!result?.pages) return `# Recent salience\n\n_No salient pages in last 14d._\n`; + // F10 bug fix (sibling of fetchRecentDecisions above): a gbrain-unreachable + // failure used to render the identical hardcoded "no salient pages" string + // as a genuine empty result, which refreshEntity() then cached as if it + // were verified truth. Unlike recent-decisions there is no project-local + // fallback for salience — it is specifically gbrain's emotional-weight- + // ranked *brain* pages, not project decision/work data, and conflating the + // two would defeat the D9 privacy allowlist's purpose. So on failure we + // return null and let the cache report 'missing' (same as product.md, + // goals.md, etc. already do on this machine) instead of asserting a claim + // we have no way to verify. + if (!result?.pages) return null; // D9 privacy gate: strip entries outside the allowlist BEFORE rendering. // Sensitive personal content (family, therapy, reflection) is never written diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 2fa6968692..b3f460377a 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -122,12 +122,23 @@ sys.exit(0) # Compute matched allowlisted, privacy-filtered path set from queue. # Output: newline-delimited relative paths that should be staged. +# +# #2549: every non-staged queue entry is CLASSIFIED, never silently discarded. +# When $2 is given, a JSON classification lands there: +# {"retained": [privacy/mode-held paths that stay queued], +# "dropped": {"skipped": [...], "invalid": [...], "unmatched": [...], "missing": [...]}} +# retained entries would sync if the user raises artifacts_sync_mode, so they +# stay in the queue; dropped classes can never sync (explicit skip, escape +# attempt, no allowlist glob, not on disk) and are removed WITH a counted +# status — the old behavior truncated the whole queue and reported every one +# of these, including privacy holds, as "no allowlisted changes". compute_paths_to_stage() { local mode="$1" - python3 - "$GSTACK_HOME" "$QUEUE" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" <<'PYEOF' + local class_file="${2:-}" + python3 - "$GSTACK_HOME" "$QUEUE" "$ALLOWLIST" "$PRIVACY_MAP" "$SKIP_FILE" "$mode" "$class_file" <<'PYEOF' import sys, json, os, fnmatch, glob -gstack_home, queue, allowlist_path, privacy_path, skip_path, mode = sys.argv[1:7] +gstack_home, queue, allowlist_path, privacy_path, skip_path, mode, class_file = sys.argv[1:8] def load_lines(path): try: @@ -195,29 +206,135 @@ def mode_allows(cls, mode): return True # full final = [] +classified = {"retained": [], "dropped": {"skipped": [], "invalid": [], "unmatched": [], "missing": []}} for p in sorted(queue_paths): if p in skip_lines: + classified["dropped"]["skipped"].append(p) continue # Must be under GSTACK_HOME root. Reject absolute + reject ../ escape. if p.startswith("/") or ".." in p.split("/"): + classified["dropped"]["invalid"].append(p) continue # Must match at least one allowlist glob. if not path_matches_any(p, allowlist_globs): + classified["dropped"]["unmatched"].append(p) continue - # Must survive privacy mode filter. + # Must survive privacy mode filter — held entries STAY QUEUED (retained): + # they would sync under a higher artifacts_sync_mode, and reporting them + # as "no allowlisted changes" was #2549's misattribution. cls = privacy_class(p, privacy_map) if not mode_allows(cls, mode): + classified["retained"].append(p) continue # Must exist on disk — can't stage what isn't there. if not os.path.exists(os.path.join(gstack_home, p)): + classified["dropped"]["missing"].append(p) continue final.append(p) +if class_file: + with open(class_file, "w") as f: + json.dump(classified, f) + for p in final: print(p) PYEOF } +# #2549: surgical queue rewrite — replaces every whole-queue truncation +# (`: > "$QUEUE"`). Re-reads the LIVE queue at rewrite time (a writer may have +# enqueued while we were staging/pushing — those entries must survive; the old +# truncation destroyed them) and keeps every line whose file is either +# retained (privacy/mode-held) or not part of this drain at all. Atomic +# tmp+mv in the same directory. Dropped-path detail goes to a 0600 sidecar so +# the status line can stay content-free (counts only). +rewrite_queue() { + local paths_file="$1" # staged (drained) paths, one per line + local class_file="$2" # classification JSON from compute_paths_to_stage + # Fail-open by design (a failed rewrite self-corrects next run: re-stage → + # nothing-to-commit), but say so — a silent failure here would let the + # subsequent "ok/idle" status claim a drain that did not happen. + python3 - "$QUEUE" "$paths_file" "$class_file" "$GSTACK_HOME/.brain-sync-drops.json" <<'PYEOF' || echo "BRAIN_SYNC: warning: queue rewrite failed — entries retained; next run re-drains" >&2 +import json, os, sys, time +queue, paths_file, class_file, drops_file = sys.argv[1:5] + +def lines(path): + try: + with open(path) as f: + return [l.rstrip("\r\n") for l in f if l.strip()] + except FileNotFoundError: + return [] + +staged = set(lines(paths_file)) +try: + with open(class_file) as f: + classified = json.load(f) +except Exception: + classified = {"retained": [], "dropped": {}} +retained = set(classified.get("retained", [])) +dropped = set() +for group in (classified.get("dropped", {}) or {}).values(): + dropped.update(group) +processed = staged | dropped + +kept = [] +seen_lines = set() +unparseable = 0 +# LIVE re-read narrows (not fully closes) the concurrent-append window: the +# lockless enqueue can still land on the old inode between this read and the +# os.replace below. Vastly better than the old whole-queue truncation. +for line in lines(queue): + if line in seen_lines: + continue # identical duplicate lines collapse on rewrite + try: + p = json.loads(line).get("file") + except Exception: + unparseable += 1 + kept.append(line) # unparseable line: keep, never destroy + seen_lines.add(line) + continue + if not isinstance(p, str) or p in retained or p not in processed: + kept.append(line) + seen_lines.add(line) +if unparseable: + import sys as _sys + print(f"BRAIN_SYNC: {unparseable} unparseable queue line(s) held (inspect {queue})", file=_sys.stderr) + +tmp = queue + ".tmp." + str(os.getpid()) +with open(tmp, "w") as f: + for l in kept: + f.write(l + "\n") +os.replace(tmp, queue) + +if dropped: + fd = os.open(drops_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + json.dump({"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "dropped": classified.get("dropped", {})}, f) +PYEOF +} + +# Human-readable classification counts for status messages. +queue_summary() { + local class_file="$1" + python3 - "$class_file" <<'PYEOF' 2>/dev/null || echo "" +import json, sys +try: + with open(sys.argv[1]) as f: + c = json.load(f) +except Exception: + print(""); sys.exit(0) +d = c.get("dropped", {}) or {} +parts = [] +r = len(c.get("retained", [])) +if r: parts.append(f"{r} privacy-held retained") +for k in ("skipped", "unmatched", "missing", "invalid"): + n = len(d.get(k, [])) + if n: parts.append(f"{n} {k} dropped") +print("; ".join(parts)) +PYEOF +} + subcmd_once() { if ! sync_active; then # Silent no-op when feature not initialized / disabled. @@ -249,20 +366,90 @@ subcmd_once() { fi fi echo "$$" > "$lock_dir/pid" 2>/dev/null || true + # Release the lock on EVERY exit from here on — including the empty-queue + # fast path and an INT during the detector's network push. Leaking it would + # rely on next-run stale-pid detection, which PID reuse can defeat (kill -0 + # matching an unrelated live process wedges sync at every boundary). The + # mktemp block below re-traps with tempfile cleanup added; both traps keep + # the lock removal. + trap 'rm -rf "$lock_dir" 2>/dev/null || true' EXIT INT TERM local mode mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) - local paths_file + # #2549 unpushed-commit detector: a prior drain may have COMMITTED but + # failed to push (auth blip, offline). The data was never lost — it sits in + # a local commit — but nothing re-pushed it until NEW changes arrived. + # Retry the push up front, inside the lock. Receipted fail-closed like + # every other push; a receipt REFUSAL skips the retry without blocking the + # rest of the drain (local staging must not wedge on receipt problems). + # Guards: origin/ may not exist yet (first sync, deleted remote). + # + # Throttled: the preamble runs --once at EVERY skill boundary, so an + # unthrottled retry would pay a full network push attempt per boundary in + # exactly the steady states this targets (offline, broken auth) — and a + # captive-portal push can block 30-75s against the header's "<1s when + # idle" promise. Attempts are recorded (success or fail) and retried at + # most every 10 minutes; the push itself never prompts for credentials and + # bounds stalled transfers via git's own low-speed limits (portable — stock + # macOS ships no `timeout` binary). + # + # Author-scoped — EXCLUSIVELY: `git push origin HEAD` publishes every + # unpushed commit, so the retry fires only when ALL unpushed commits are + # gstack-brain-sync's own. One interleaved user commit disables the + # auto-retry entirely (adversarial review: an existential check would + # silently auto-publish a user's manual ~/.gstack commit the moment a bot + # commit sat in front of it). User commits ride along when a REAL drain + # pushes, as before — the detector never publishes work it didn't create. + local det_branch det_unpushed det_total det_now det_last + det_branch=$(git -C "$GSTACK_HOME" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + # Detached HEAD reads as the literal "HEAD" — origin/HEAD usually resolves, + # so without this exclusion the detector would retry a doomed push forever. + [ "$det_branch" = "HEAD" ] && det_branch="" + if [ -n "$det_branch" ] && git -C "$GSTACK_HOME" rev-parse --verify --quiet "origin/$det_branch" >/dev/null 2>&1; then + det_unpushed=$(git -C "$GSTACK_HOME" rev-list --count --author="gstack-brain-sync" "origin/$det_branch..HEAD" 2>/dev/null || echo 0) + det_total=$(git -C "$GSTACK_HOME" rev-list --count "origin/$det_branch..HEAD" 2>/dev/null || echo 0) + case "$det_unpushed" in ''|*[!0-9]*) det_unpushed=0 ;; esac + case "$det_total" in ''|*[!0-9]*) det_total=0 ;; esac + det_now=$(date +%s) + det_last=$(cat "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || echo 0) + case "$det_last" in ''|*[!0-9]*) det_last=0 ;; esac + if [ "$det_unpushed" -gt 0 ] && [ "$det_unpushed" -eq "$det_total" ] && [ $(( det_now - det_last )) -ge 600 ]; then + echo "$det_now" > "$GSTACK_HOME/.brain-last-push-attempt" 2>/dev/null || true + local det_host + det_host=$(remote_host) + if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$det_host" curated-memory-git-push "artifacts_sync_mode!=off" \ + bash -c 'GIT_TERMINAL_PROMPT=0 git -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then + date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE" + fi + fi + fi + + # Empty-queue fast path: this is the steady state at every skill boundary. + # Skipping compute/rewrite here is safe — with zero queue lines there is + # nothing to classify, retain, or drop, and a concurrent append after this + # check simply waits for the next boundary. (The detector above already ran: + # its whole point is re-pushing stranded commits when the queue is empty.) + # The lock-release trap installed at acquisition covers this exit. + if [ ! -s "$QUEUE" ]; then + write_status "idle" "queue empty" + exit 0 + fi + + local paths_file class_file paths_file=$(mktemp /tmp/brain-sync-paths.XXXXXX) || { rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; } - # Single trap covers both: lock cleanup AND tempfile cleanup. - trap 'rm -f "$paths_file" 2>/dev/null; rm -rf "$lock_dir" 2>/dev/null || true' EXIT INT TERM + class_file=$(mktemp /tmp/brain-sync-class.XXXXXX) || { rm -f "$paths_file"; rm -rf "$lock_dir" 2>/dev/null; write_status "error" "mktemp failed"; exit 1; } + # Single trap covers all: lock cleanup AND tempfile cleanup. + trap 'rm -f "$paths_file" "$class_file" 2>/dev/null; rm -rf "$lock_dir" 2>/dev/null || true' EXIT INT TERM - compute_paths_to_stage "$mode" > "$paths_file" + compute_paths_to_stage "$mode" "$class_file" > "$paths_file" if [ ! -s "$paths_file" ]; then - # Nothing to stage. Clear any stale queue entries and exit. - : > "$QUEUE" - write_status "idle" "no allowlisted changes in queue" + # Nothing stageable. Rewrite the queue (retained entries + concurrent + # appends survive; classified drops removed) instead of truncating it. + rewrite_queue "$paths_file" "$class_file" + local summary + summary=$(queue_summary "$class_file") + write_status "idle" "no stageable changes${summary:+ ($summary)}" exit 0 fi @@ -309,8 +496,9 @@ subcmd_once() { local msg="sync: $n file(s) | $ts" git -C "$GSTACK_HOME" -c user.email="gstack@localhost" -c user.name="gstack-brain-sync" \ commit -q -m "$msg" 2>/dev/null || { - # Nothing to commit (e.g. all files already committed). - : > "$QUEUE" + # Nothing to commit (e.g. all files already committed). The drained + # paths leave the queue; retained + concurrent entries survive (#2549). + rewrite_queue "$paths_file" "$class_file" write_status "idle" "queue drained but no new changes to commit" exit 0 } @@ -322,10 +510,12 @@ subcmd_once() { if echo "$push_err" | grep -qiE "auth|permission|403|401|forbidden"; then local hint hint=$(remote_auth_hint) - write_status "push_failed" "push failed: auth error. fix: $hint" + write_status "push_failed" "push failed: auth error; commit retained locally, will retry next run. fix: $hint" echo "BRAIN_SYNC: push failed: auth. fix: $hint" >&2 - # Queue cleared because the commit exists locally; next push will send it. - : > "$QUEUE" + # Drained paths leave the queue — they live in the local commit, which + # the run-start detector re-pushes next time (#2549). Retained + + # concurrent entries survive the rewrite. + rewrite_queue "$paths_file" "$class_file" exit 0 fi @@ -339,20 +529,21 @@ subcmd_once() { if git -C "$GSTACK_HOME" merge --no-edit "origin/$branch" >/dev/null 2>&1; then if GSTACK_HOME="$GSTACK_HOME" _receipted_git closed brain-sync "$push_host" curated-memory-git-push "artifacts_sync_mode!=off" \ bash -c 'git -C "$1" push origin HEAD 2>/dev/null' _ "$GSTACK_HOME"; then - : > "$QUEUE" + rewrite_queue "$paths_file" "$class_file" date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE" write_status "ok" "pushed $n file(s) after rebase" exit 0 fi fi fi - write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1)" - : > "$QUEUE" + # Commit exists locally; the run-start detector re-pushes it next time. + write_status "push_failed" "push failed: $(printf '%s' "$push_err" | head -1); commit retained locally, will retry next run" + rewrite_queue "$paths_file" "$class_file" exit 0 } - # Success: clear queue, update last-push. - : > "$QUEUE" + # Success: drained paths leave the queue (retained + concurrent survive). + rewrite_queue "$paths_file" "$class_file" date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_PUSH_FILE" write_status "ok" "pushed $n file(s)" exit 0 diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index 940dacf842..2d151ef60e 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -4,6 +4,7 @@ # # Functions (all prefixed with _gstack_codex_ for namespace hygiene): # _gstack_codex_auth_probe — multi-signal auth check (env + file) +# _gstack_codex_model_probe — round-trip probe of the configured model (#2477) # _gstack_codex_version_check — warn on known-bad Codex CLI versions # _gstack_codex_timeout_wrapper — gtimeout -> timeout -> unwrapped fallback # _gstack_codex_log_event — telemetry emission to ~/.gstack/analytics/ @@ -33,6 +34,92 @@ _gstack_codex_auth_probe() { return 1 } +# --- Model round-trip probe (#2477) ------------------------------------------ + +_gstack_codex_model_probe() { + # Auth-exists is a weaker signal than the auth probe implies: a ChatGPT + # account with a stale `model = "..."` pin in ~/.codex/config.toml passes + # the auth probe, then EVERY invocation dies with an HTTP 400 ("The + # '' model is not supported when using Codex with a ChatGPT + # account") and no guidance. A short real round trip with the configured + # model catches model rejection, entitlement changes, and stale pins in + # one shot (#2477). + # + # Contract: + # MODEL_OK (exit 0) — round trip succeeded; cached 1h. + # MODEL_UNUSABLE (exit 1) — deterministic model 400; hints printed. + # Cached 15 min: the 400 is config-driven, so re-probing every preflight + # charged the affected user a 30s round trip + real tokens per review + # section, forever. Editing config.toml (the fix) changes the cache + # signature and re-probes immediately; the short TTL covers server-side + # entitlement recovery the signature can't see. + # MODEL_PROBE_INCONCLUSIVE (exit 0) — timeout/transient; FAIL-OPEN so a + # slow network never wedges codex mode (the per-invocation Error + # Handling entry still covers a later 400). Never cached. + # + # Only call this AFTER _gstack_codex_auth_probe passes — probing without + # auth just measures the auth failure again. + local _codex_home="${CODEX_HOME:-$HOME/.codex}" + local _gstack_home="${GSTACK_HOME:-$HOME/.gstack}" + local _cache="$_gstack_home/.codex-model-probe" + # Cache signature: config.toml + auth.json mtimes. Editing the model pin + # or re-logging-in invalidates the cached MODEL_OK immediately. + # GNU-first stat order + numeric validation (the #2195 pattern): on GNU + # stat, `-f` means FILESYSTEM mode, so the BSD-first form emitted a + # multi-line filesystem block on Linux — the signature then never matched + # its own cache line and the cache missed on every read. BSD stat rejects + # `-c` cleanly, so GNU-first degrades correctly on macOS. + local _cfg_m _auth_m _sig + _cfg_m=$(stat -c %Y "$_codex_home/config.toml" 2>/dev/null || stat -f %m "$_codex_home/config.toml" 2>/dev/null || echo 0) + _auth_m=$(stat -c %Y "$_codex_home/auth.json" 2>/dev/null || stat -f %m "$_codex_home/auth.json" 2>/dev/null || echo 0) + case "$_cfg_m" in ''|*[!0-9]*) _cfg_m=0 ;; esac + case "$_auth_m" in ''|*[!0-9]*) _auth_m=0 ;; esac + _sig="${_cfg_m}-${_auth_m}" + local _now + _now=$(date +%s 2>/dev/null || echo 0) + if [ -f "$_cache" ]; then + local _c_line _c_status _c_ts _c_sig + _c_line=$(head -1 "$_cache" 2>/dev/null) + _c_status=$(printf '%s' "$_c_line" | cut -d' ' -f1) + _c_ts=$(printf '%s' "$_c_line" | cut -d' ' -f2) + _c_sig=$(printf '%s' "$_c_line" | cut -d' ' -f3) + case "$_c_ts" in ''|*[!0-9]*) _c_ts=0 ;; esac + if [ "$_c_status" = "MODEL_OK" ] && [ "$_c_sig" = "$_sig" ] && [ $((_now - _c_ts)) -lt 3600 ]; then + echo "MODEL_OK (cached)" + return 0 + fi + if [ "$_c_status" = "MODEL_UNUSABLE" ] && [ "$_c_sig" = "$_sig" ] && [ $((_now - _c_ts)) -lt 900 ]; then + echo "MODEL_UNUSABLE (cached)" + echo "HINT: the rejected model comes from the 'model = ' line in $_codex_home/config.toml." + echo "HINT: check its [notice.model_migrations] table — Codex records the intended replacement there." + return 1 + fi + fi + local _out _code + _out=$(_gstack_codex_timeout_wrapper 30 codex exec --skip-git-repo-check -s read-only "reply OK" &1) + _code=$? + if [ "$_code" -eq 0 ]; then + mkdir -p "$_gstack_home" 2>/dev/null || true + printf 'MODEL_OK %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true + echo "MODEL_OK" + return 0 + fi + if printf '%s' "$_out" | grep -qiE 'model.{0,40}is not supported|"status":[[:space:]]*400'; then + mkdir -p "$_gstack_home" 2>/dev/null || true + printf 'MODEL_UNUSABLE %s %s\n' "$_now" "$_sig" > "$_cache" 2>/dev/null || true + echo "MODEL_UNUSABLE" + printf '%s\n' "$_out" | grep -i "model" | head -3 + echo "HINT: the rejected model comes from the 'model = ' line in $_codex_home/config.toml." + echo "HINT: check its [notice.model_migrations] table — Codex records the intended replacement there." + _gstack_codex_log_event "codex_model_unusable" 2>/dev/null || true + return 1 + fi + # Timeout (124) or transient failure: fail-open with a warning. The probe + # exists to catch the deterministic model 400, not to gate on network luck. + echo "MODEL_PROBE_INCONCLUSIVE (exit $_code) — proceeding; if invocations fail with a model 400, see the codex skill's Error Handling entry." + return 0 +} + # --- Version check ---------------------------------------------------------- _gstack_codex_version_check() { @@ -53,8 +140,8 @@ _gstack_codex_version_check() { _gstack_codex_timeout_wrapper() { # Resolve wrapper binary: prefer gtimeout (Homebrew coreutils on macOS), - # fall back to timeout (Linux), else run unwrapped. Arguments: $1 is the - # duration in seconds; rest is the command to run. + # fall back to timeout (Linux), else a bash-native watchdog. Arguments: + # $1 is the duration in seconds; rest is the command to run. local _duration="$1" shift local _to @@ -62,7 +149,29 @@ _gstack_codex_timeout_wrapper() { if [ -n "$_to" ]; then "$_to" "$_duration" "$@" else - "$@" + # Stock macOS ships neither coreutils gtimeout nor timeout(1); running + # unwrapped let a hung `codex exec` block the probe — and the calling + # workflow — indefinitely. Emulate: background the command, TERM it at + # the deadline, mirror timeout(1)'s exit-124 contract. The watchdog's + # stdout is detached so an early finish never blocks a caller's $(...) + # capture on the orphaned sleep. + "$@" & + local _cmd_pid=$! + ( sleep "$_duration" && kill -TERM "$_cmd_pid" 2>/dev/null ) >/dev/null 2>&1 & + local _watch_pid=$! + local _rc + wait "$_cmd_pid" + _rc=$? + if kill -0 "$_watch_pid" 2>/dev/null; then + # Command finished before the deadline. Retiring the watchdog subshell + # also defuses its pending kill (the `&& kill` lives in the subshell); + # its detached sleep expires harmlessly. + kill "$_watch_pid" 2>/dev/null + wait "$_watch_pid" 2>/dev/null + elif [ "$_rc" -ge 128 ]; then + _rc=124 # killed by the watchdog: report timeout(1)'s code + fi + return "$_rc" fi } @@ -72,7 +181,7 @@ _gstack_codex_log_event() { # Emit a telemetry event to ~/.gstack/analytics/skill-usage.jsonl. # Gated on $_TEL != "off" (caller sets this from gstack-config). # Event types: codex_timeout, codex_auth_failed, codex_cli_missing, - # codex_version_warning. + # codex_version_warning, codex_model_unusable. # Payload schema: {skill, event, duration_s, ts}. NEVER includes prompt # content, env var values, or auth tokens. local _event="$1" diff --git a/bin/gstack-config b/bin/gstack-config index 575f935236..b8adf9c254 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -17,6 +17,21 @@ set -euo pipefail STATE_DIR="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-${GSTACK_STATE_DIR:-$HOME/.gstack}}}" CONFIG_FILE="$STATE_DIR/config.yaml" +# Swap a freshly-rendered tmp dir into the live render location (#2569 +# hardening). Installed skills SYMLINK into the live dir, so it is only ever +# replaced AFTER a successful render — a failed render leaves the previous +# render (and every link into it) fully intact. Keep in sync with setup's +# _swap_in_render (same contract, both pinned by +# test/user-render-out-dir-install.test.ts). +_swap_in_render() { + local render_dir="$1" render_tmp="$2" + local render_old="$render_dir.old.$$" + rm -rf "$render_old" + if [ -e "$render_dir" ] || [ -L "$render_dir" ]; then mv "$render_dir" "$render_old"; fi + mv "$render_tmp" "$render_dir" + rm -rf "$render_old" +} + # Annotated header for new config files. Written once on first `set`. # Default semantics: DEFAULTS table below is the canonical source. Header text # is documentation that must stay in sync with DEFAULTS. @@ -434,33 +449,55 @@ case "${1:-}" in fi case "$STATUS" in - ok|timeout|thin-client) + ok|timeout|thin-client|engine-locked) # "timeout" = slow-but-healthy engine (#1964); "thin-client" = - # remote-HTTP MCP brain, no local engine by design (#2051) — same - # treatment as "ok", matching gstack-gbrain-detect --is-ok and - # gen-skill-docs. + # remote-HTTP MCP brain, no local engine by design (#2051); + # "engine-locked" = same class (#2456): PGLite is single-writer, so a + # live `gbrain serve` (typically an MCP server) holds the embedded DB. + # gbrain is installed and healthy; a transient lock must not strip + # brain blocks out of every SKILL.md. All get the same treatment as + # "ok", matching gstack-gbrain-detect --is-ok and gen-skill-docs. echo "Detected gbrain v$VERSION (local-status: $STATUS)." - # Render brain-aware blocks INTO the global install so EVERY project's - # Claude sessions get them (other projects read SKILL.md + sections from - # ~/.claude/skills/gstack via absolute paths baked at gen time). Guards - # (never mutate an arbitrary directory): the target must exist, not be a - # symlink (a symlinked install points at a dev worktree — rendering there - # would dirty tracked source), and look like a real gstack clone. + # Render brain-aware blocks into an UNTRACKED out-dir (#2569) and + # repoint the installed skills at it — the old in-place render wrote + # into TRACKED files of the global install checkout, so the checkout + # stayed permanently dirty and every upgrade grew a redundant stash. + # Guards (never mutate an arbitrary directory): the install must + # exist, not be a symlink (a symlinked install points at a dev + # worktree — bin/dev-setup owns that flow), and look like a real + # gstack clone. INSTALL_DIR="$HOME/.claude/skills/gstack" + RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" if [ ! -d "$INSTALL_DIR" ]; then echo "No global install at $INSTALL_DIR — nothing to render. (Dev workspaces get blocks via bin/dev-setup.)" elif [ -L "$INSTALL_DIR" ]; then - echo "Skip: $INSTALL_DIR is a symlink (likely a dev worktree). Rendering there would dirty tracked source — run bin/dev-setup in that worktree instead." + echo "Skip: $INSTALL_DIR is a symlink (likely a dev worktree). Run bin/dev-setup in that worktree instead." elif [ ! -f "$INSTALL_DIR/VERSION" ] || [ ! -f "$INSTALL_DIR/package.json" ]; then echo "Skip: $INSTALL_DIR doesn't look like a gstack clone (missing VERSION/package.json) — refusing to modify it." elif ! command -v bun >/dev/null 2>&1; then echo "Skip: bun not on PATH — can't render. Install bun, then re-run 'gstack-config gbrain-refresh'." - elif ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude >/dev/null 2>&1 ); then - echo "Rendered brain-aware blocks into $INSTALL_DIR — now live across all your projects' Claude sessions." - echo "Note: this dirties the install's git tree (generated blocks differ from main, by design)." - echo " A 'git reset --hard origin/main' there reverts them; re-run 'gstack-config gbrain-refresh' to restore." else - echo "Warning: render failed. Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude' manually to see the error." + # Render into a tmp dir and swap it in only on SUCCESS. Installed + # skills SYMLINK into $RENDER_DIR (gstack-relink prefers it), so + # wiping it before the render meant one transient failure (bun + # error, disk full, broken template) left every brain-aware + # SKILL.md link dangling — the whole skill set vanished from + # Claude Code until a successful re-render. A failed render now + # leaves the previous render fully intact. + RENDER_TMP="$RENDER_DIR.tmp.$$" + rm -rf "$RENDER_TMP" + if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" >/dev/null 2>&1 ); then + _swap_in_render "$RENDER_DIR" "$RENDER_TMP" + # Repoint installed skills at the render — gstack-relink prefers + # the render dir when present. + "$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true + echo "Rendered brain-aware blocks into $RENDER_DIR — now live across all your projects' Claude sessions." + echo "The install checkout stays clean: upgrades no longer stash generated render dirt (#2569)." + else + rm -rf "$RENDER_TMP" + echo "Warning: render failed — previous render (if any) left in place, links stay valid." + echo "Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude --out-dir $RENDER_DIR' manually to see the error." + fi fi ;; *) diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index 38450e6b14..17da9c6567 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -2,6 +2,24 @@ # gstack-diff-scope — categorize what changed in the diff against a base branch # Usage: source <(gstack-diff-scope main) → sets SCOPE_FRONTEND=true SCOPE_BACKEND=false ... # Or: gstack-diff-scope main → prints SCOPE_*=... lines +# +# Output contract (#2526 — all-false must be distinguishable from "we could +# not look" and from "nothing matched"): +# exit 0 changed-file set empty → all false, legitimately nothing +# exit 0 changed files, >=1 category match → flags +# exit 2 changed files, ZERO matches → flags + SCOPE_ERROR=unmatched +# (+ the unmatched paths as comment lines, so a new top-level layout +# trips loudly instead of silently disabling reviewers) +# exit 2 base ref unresolvable → all false + SCOPE_ERROR=no_base +# (shallow CI checkout / missing fetch — a green here would mean +# "we could not look") +# Every line is shell-safe for `source <(...)` consumers: assignments or +# `#`-comments only. +# +# The changed-file set is the UNION of committed diff + working tree + +# untracked files (#2299): /ship detects scope in Step 9, BEFORE it commits in +# Step 15, so uncommitted work must be visible or every scope-gated reviewer +# is skipped on the common start-work-then-ship flow. set -euo pipefail # Detect the repo's default branch when no arg is given (#703-class @@ -14,22 +32,6 @@ _default_base() { } BASE="${1:-$(_default_base)}" -# Get changed file list -FILES=$(git diff "${BASE}...HEAD" --name-only 2>/dev/null || git diff "${BASE}" --name-only 2>/dev/null || echo "") - -if [ -z "$FILES" ]; then - echo "SCOPE_FRONTEND=false" - echo "SCOPE_BACKEND=false" - echo "SCOPE_PROMPTS=false" - echo "SCOPE_TESTS=false" - echo "SCOPE_DOCS=false" - echo "SCOPE_CONFIG=false" - echo "SCOPE_MIGRATIONS=false" - echo "SCOPE_API=false" - echo "SCOPE_AUTH=false" - exit 0 -fi - FRONTEND=false BACKEND=false PROMPTS=false @@ -40,62 +42,162 @@ MIGRATIONS=false API=false AUTH=false -while IFS= read -r f; do +_print_flags() { + echo "SCOPE_FRONTEND=$FRONTEND" + echo "SCOPE_BACKEND=$BACKEND" + echo "SCOPE_PROMPTS=$PROMPTS" + echo "SCOPE_TESTS=$TESTS" + echo "SCOPE_DOCS=$DOCS" + echo "SCOPE_CONFIG=$CONFIG" + echo "SCOPE_MIGRATIONS=$MIGRATIONS" + echo "SCOPE_API=$API" + echo "SCOPE_AUTH=$AUTH" +} + +# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes +# `git diff` return an empty list — all-false with exit 0, a green that means +# "we could not look". Distinguish it before diffing. +if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then + _print_flags + echo "SCOPE_ERROR=no_base" + echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}" + exit 2 +fi + +# Changed files, NUL-delimited (#2526 minor: `git diff --name-only` octal-quotes +# non-ASCII paths, and the trailing quote defeats extension globs; -z avoids it). +FILES_LIST=() +_collect() { + local f + while IFS= read -r -d '' f; do + [ -n "$f" ] && FILES_LIST+=("$f") + done +} +# Committed diff vs base (merge-base form; two-dot fallback when no merge base). +_collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true) +# Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo +# with no commits; tolerated. +_collect < <(git diff -z HEAD --name-only 2>/dev/null || true) +# Untracked files: a brand-new component/migration/test is exactly what a +# reviewer should see, and /ship commits it in Step 15 regardless. +_collect < <(git ls-files -z --others --exclude-standard 2>/dev/null || true) + +if [ "${#FILES_LIST[@]}" -eq 0 ]; then + _print_flags + exit 0 +fi + +UNMATCHED=() + +# Categories are INDEPENDENT booleans (#2299): a single first-match-wins case +# made them mutually exclusive, so Button.test.jsx set FRONTEND but not TESTS +# while util.test.ts set TESTS but not BACKEND — same intent, opposite result, +# purely from arm ordering. Each category now gets its own case; only BACKEND +# stays deliberately exclusive of frontend component/view files. +for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do + m_frontend=false; m_prompts=false; m_tests=false; m_docs=false + m_config=false; m_migrations=false; m_api=false; m_auth=false; m_backend=false + + # Frontend: CSS, views, components, templates + case "$f" in + *.css|*.scss|*.less|*.sass|*.pcss) m_frontend=true ;; + *.tsx|*.jsx|*.vue|*.svelte|*.astro) m_frontend=true ;; + *.erb|*.haml|*.slim|*.hbs|*.ejs) m_frontend=true ;; + *.html) m_frontend=true ;; + tailwind.config.*|postcss.config.*) m_frontend=true ;; + app/views/*|*/components/*|styles/*|css/*|app/assets/stylesheets/*) m_frontend=true ;; + esac + + # Prompts: prompt builders, system prompts, generation services case "$f" in - # Frontend: CSS, views, components, templates - *.css|*.scss|*.less|*.sass|*.pcss|*.module.css|*.module.scss) FRONTEND=true ;; - *.tsx|*.jsx|*.vue|*.svelte|*.astro) FRONTEND=true ;; - *.erb|*.haml|*.slim|*.hbs|*.ejs) FRONTEND=true ;; - *.html) FRONTEND=true ;; - tailwind.config.*|postcss.config.*) FRONTEND=true ;; - app/views/*|*/components/*|styles/*|css/*|app/assets/stylesheets/*) FRONTEND=true ;; - - # Prompts: prompt builders, system prompts, generation services - *prompt_builder*|*generation_service*|*writer_service*|*designer_service*) PROMPTS=true ;; - *evaluator*|*scorer*|*classifier_service*|*analyzer*) PROMPTS=true ;; - *voice*.rb|*writing*.rb|*prompt*.rb|*token*.rb) PROMPTS=true ;; - app/services/chat_tools/*|app/services/x_thread_tools/*) PROMPTS=true ;; - config/system_prompts/*) PROMPTS=true ;; - - # Tests - *.test.*|*.spec.*|*_test.*|*_spec.*) TESTS=true ;; - test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) TESTS=true ;; - - # Docs - *.md) DOCS=true ;; - - # Config - package.json|package-lock.json|yarn.lock|bun.lock|bun.lockb) CONFIG=true ;; - Gemfile|Gemfile.lock) CONFIG=true ;; - *.yml|*.yaml) CONFIG=true ;; - .github/*) CONFIG=true ;; - requirements.txt|pyproject.toml|go.mod|Cargo.toml|composer.json) CONFIG=true ;; - - # Migrations: database migration files - db/migrate/*|*/migrations/*|alembic/*|prisma/migrations/*) MIGRATIONS=true ;; - - # API: routes, controllers, endpoints, GraphQL/OpenAPI schemas - *controller*|*route*|*endpoint*|*/api/*) API=true ;; - *.graphql|*.gql|openapi.*|swagger.*) API=true ;; - - # Auth: authentication, authorization, sessions, permissions - *auth*|*session*|*jwt*|*oauth*|*permission*|*role*) AUTH=true ;; - - # Backend: everything else that's code (excluding views/components already matched) - *.rb|*.py|*.go|*.rs|*.java|*.php|*.ex|*.exs) BACKEND=true ;; - # Non-component TS/JS is backend. Include ESM/CJS (.mjs/.cjs) and - # explicit-module TS (.mts/.cts) — #1810: these matched no category, so an - # ESM/CJS-only PR skipped the backend reviewer entirely. - *.ts|*.js|*.mjs|*.cjs|*.mts|*.cts) BACKEND=true ;; + *prompt_builder*|*generation_service*|*writer_service*|*designer_service*) m_prompts=true ;; + *evaluator*|*scorer*|*classifier_service*|*analyzer*) m_prompts=true ;; + *voice*.rb|*writing*.rb|*prompt*.rb|*token*.rb) m_prompts=true ;; + app/services/chat_tools/*|app/services/x_thread_tools/*) m_prompts=true ;; + config/system_prompts/*) m_prompts=true ;; esac -done <<< "$FILES" - -echo "SCOPE_FRONTEND=$FRONTEND" -echo "SCOPE_BACKEND=$BACKEND" -echo "SCOPE_PROMPTS=$PROMPTS" -echo "SCOPE_TESTS=$TESTS" -echo "SCOPE_DOCS=$DOCS" -echo "SCOPE_CONFIG=$CONFIG" -echo "SCOPE_MIGRATIONS=$MIGRATIONS" -echo "SCOPE_API=$API" -echo "SCOPE_AUTH=$AUTH" + + # Tests + case "$f" in + *.test.*|*.spec.*|*_test.*|*_spec.*) m_tests=true ;; + test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) m_tests=true ;; + esac + + # Docs + case "$f" in + *.md) m_docs=true ;; + esac + + # Config + case "$f" in + package.json|package-lock.json|yarn.lock|bun.lock|bun.lockb) m_config=true ;; + Gemfile|Gemfile.lock) m_config=true ;; + *.yml|*.yaml) m_config=true ;; + .github/*) m_config=true ;; + requirements.txt|pyproject.toml|go.mod|Cargo.toml|composer.json) m_config=true ;; + esac + + # Migrations: database migration files. Bare migrations/* covers a + # root-level migrations dir (#2526); db/data covers the Rails data_migrate + # gem's DATA migrations (#2455) — arbitrary Ruby run unattended against + # production data, strictly higher-risk than a schema migration (they also + # match BACKEND below via their extension, as ordinary app code should). + case "$f" in + db/migrate/*|migrations/*|*/migrations/*|alembic/*|prisma/migrations/*) m_migrations=true ;; + db/data/*|data_migrations/*|*/data_migrations/*) m_migrations=true ;; + esac + + # API: routes, controllers, endpoints, GraphQL/OpenAPI schemas. Bare api/* + # covers root-level serverless layouts (Vercel functions, Next.js pages/api + # at root) that */api/* silently missed (#2526). + case "$f" in + api/*|*/api/*|*controller*|*route*|*endpoint*) m_api=true ;; + *.graphql|*.gql|openapi.*|swagger.*) m_api=true ;; + esac + + # Auth: authentication, authorization, sessions, permissions + case "$f" in + *auth*|*session*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;; + esac + + # Backend: code that isn't a frontend component/view file. Includes ESM/CJS + # (.mjs/.cjs) and explicit-module TS (.mts/.cts) — #1810: these matched no + # category, so an ESM/CJS-only PR skipped the backend reviewer entirely. + if [ "$m_frontend" = false ]; then + case "$f" in + *.rb|*.py|*.go|*.rs|*.java|*.php|*.ex|*.exs) m_backend=true ;; + *.ts|*.js|*.mjs|*.cjs|*.mts|*.cts) m_backend=true ;; + esac + fi + + [ "$m_frontend" = true ] && FRONTEND=true + [ "$m_prompts" = true ] && PROMPTS=true + [ "$m_tests" = true ] && TESTS=true + [ "$m_docs" = true ] && DOCS=true + [ "$m_config" = true ] && CONFIG=true + [ "$m_migrations" = true ] && MIGRATIONS=true + [ "$m_api" = true ] && API=true + [ "$m_auth" = true ] && AUTH=true + [ "$m_backend" = true ] && BACKEND=true + + if [ "$m_frontend" = false ] && [ "$m_prompts" = false ] && [ "$m_tests" = false ] \ + && [ "$m_docs" = false ] && [ "$m_config" = false ] && [ "$m_migrations" = false ] \ + && [ "$m_api" = false ] && [ "$m_auth" = false ] && [ "$m_backend" = false ]; then + UNMATCHED+=("$f") + fi +done + +_print_flags + +# Changed files but ZERO category matches (#2526): a classifier bug, an +# unrecognised layout, or a new top-level directory would otherwise present +# as "no reviewers needed" with the skip invisible. Trip loudly instead. +if [ "$FRONTEND" = false ] && [ "$BACKEND" = false ] && [ "$PROMPTS" = false ] \ + && [ "$TESTS" = false ] && [ "$DOCS" = false ] && [ "$CONFIG" = false ] \ + && [ "$MIGRATIONS" = false ] && [ "$API" = false ] && [ "$AUTH" = false ]; then + echo "SCOPE_ERROR=unmatched" + printf '%s\n' ${UNMATCHED[@]+"${UNMATCHED[@]}"} | sort -u | head -50 | while IFS= read -r u; do + [ -n "$u" ] && printf '# unmatched: %s\n' "$u" + done + exit 2 +fi diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index 19797a495b..3774f6bacc 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -43,18 +43,17 @@ import { resolveGbrainBin, readGbrainVersion, } from "../lib/gbrain-local-status"; -import { isTransactionModePooler } from "../lib/gbrain-exec"; +import { gbrainConfigDir, isTransactionModePooler } from "../lib/gbrain-exec"; const STATE_DIR = process.env.GSTACK_HOME || join(userHome(), ".gstack"); const SCRIPT_DIR = __dirname; const CONFIG_BIN = join(SCRIPT_DIR, "gstack-config"); -// Honors GBRAIN_HOME — must stay consistent with lib/gbrain-local-status's -// config resolution, or the detect JSON reports gbrain_local_status "ok" -// alongside gbrain_config_exists false for relocated-home users. -const GBRAIN_CONFIG = join( - process.env.GBRAIN_HOME || join(userHome(), ".gbrain"), - "config.json", -); +// Honors GBRAIN_HOME with gbrain's own configDir() semantics (#2521: +// GBRAIN_HOME is a parent dir, `.gbrain` is appended) — must stay consistent +// with lib/gbrain-local-status's config resolution, or the detect JSON +// reports gbrain_local_status "ok" alongside gbrain_config_exists false for +// relocated-home users. Both route through gbrainConfigDir. +const GBRAIN_CONFIG = join(gbrainConfigDir(), "config.json"); const CLAUDE_JSON = join(userHome(), ".claude.json"); function userHome(): string { @@ -232,8 +231,7 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" { /** remote_mcp.mcp_url from gbrain's own config (thin-client marker, #2051). */ function readRemoteMcpUrl(): string { - const gbrainHome = process.env.GBRAIN_HOME || join(userHome(), ".gbrain"); - const cfg = tryReadJSON(join(gbrainHome, "config.json")) as + const cfg = tryReadJSON(join(gbrainConfigDir(), "config.json")) as | { remote_mcp?: { mcp_url?: string } } | null; return cfg?.remote_mcp?.mcp_url || ""; @@ -288,17 +286,23 @@ function main(): void { } // --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok"; -// "timeout" — a slow-but-healthy engine, #1964; or "thin-client" — remote-HTTP -// MCP brain with no local engine by design, #2051 — neither slow nor remote -// must silently suppress brain features), 1 otherwise. Runs detection live -// (never reads the possibly-stale gbrain-detection.json), so callers — setup, -// bin/dev-setup, and `gstack-config gbrain-refresh` — can decide whether to -// render the gbrain :user variant without duplicating the JSON grep. -// Prints nothing on stdout. +// "timeout" — a slow-but-healthy engine, #1964; "thin-client" — remote-HTTP +// MCP brain with no local engine by design, #2051; or "engine-locked" — +// PGLite is single-writer, so a live `gbrain serve` (typically an MCP +// server) holds the embedded DB, #2456 — gbrain is installed and healthy in +// all four; none must silently suppress brain features), 1 otherwise. Runs +// detection live (never reads the possibly-stale gbrain-detection.json), so +// callers — setup, bin/dev-setup, and `gstack-config gbrain-refresh` — can +// decide whether to render the gbrain :user variant without duplicating the +// JSON grep. Prints nothing on stdout. if (process.argv.includes("--is-ok")) { const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1"; const status = localEngineStatus({ noCache }); - process.exit(status === "ok" || status === "timeout" || status === "thin-client" ? 0 : 1); + process.exit( + status === "ok" || status === "timeout" || status === "thin-client" || status === "engine-locked" + ? 0 + : 1, + ); } main(); diff --git a/bin/gstack-gbrain-install b/bin/gstack-gbrain-install index 60c8f86b66..84091c2368 100755 --- a/bin/gstack-gbrain-install +++ b/bin/gstack-gbrain-install @@ -84,7 +84,14 @@ if ! $VALIDATE_ONLY; then # GitHub reachability — fail fast if offline rather than hanging `git clone`. # --max-time 10, --head (no body), quiet. Status code 200-4xx means we reached # the server (even 404 is reachability proof). - if ! curl -s --head --max-time 10 https://github.com >/dev/null 2>&1; then + # + # Skipped under --dry-run: a dry run prints a plan and exits without ever + # cloning, so requiring the network buys nothing and costs a real failure mode. + # It made `--dry-run` fail (exit 3, "cannot reach https://github.com") whenever + # the curl lost a race for sockets/DNS — reproducible at ~15% by running 60 + # dry-runs concurrently, and the cause of intermittent red in the D5 tests, + # which call this exact path. + if ! $DRY_RUN && ! curl -s --head --max-time 10 https://github.com >/dev/null 2>&1; then fail "cannot reach https://github.com. Check your network and try again." fi fi @@ -168,6 +175,26 @@ if ! $VALIDATE_ONLY; then ( cd "$INSTALL_DIR" && bun link --silent ) fi +# #2487: an npm-installed bun (`npm i -g bun`) puts a POSIX script + .cmd/.ps1 +# shims on %PATH% but never bun.exe — and the gbrain.exe shim that `bun link` +# generates resolves bun.exe SPECIFICALLY. Link "succeeds", then every gbrain +# call dies with bun's misleading "bun is not installed in %PATH%" (suggesting +# a second parallel bun install). Detect the condition and name the real fix: +# bun's own process.execPath IS the hidden bun.exe. +_bun_exe_hint() { + [ "$IS_WINDOWS" -eq 1 ] || return 0 + command -v bun.exe >/dev/null 2>&1 && return 0 + local real_bun + real_bun=$(bun -e 'console.log(process.execPath)' 2>/dev/null | tr -d '\r' || true) + echo " detected: bun was installed via npm — bun.exe is NOT on %PATH%, and the gbrain.exe shim needs it." >&2 + if [ -n "$real_bun" ]; then + echo " fix: add bun.exe's directory to PATH (persist it in your shell profile):" >&2 + echo " export PATH=\"$(dirname "$real_bun"):\$PATH\"" >&2 + else + echo " fix: install bun via the official installer (https://bun.sh) or add the directory containing bun.exe to %PATH%." >&2 + fi +} + # --- D19 PATH-shadowing validation --- # Read the version from the install-dir's package.json; compare to # `gbrain --version`. If they disagree, PATH is returning a DIFFERENT @@ -178,11 +205,13 @@ if [ -z "$expected_version" ]; then fi if ! command -v gbrain >/dev/null 2>&1; then + _bun_exe_hint fail "bun link completed but 'gbrain' is not on PATH. Ensure ~/.bun/bin is in your PATH." fi actual_version=$(gbrain --version 2>/dev/null | head -1 | awk '{print $NF}' | tr -d '[:space:]' || true) if [ -z "$actual_version" ]; then + _bun_exe_hint fail "gbrain is on PATH but 'gbrain --version' produced no output — the binary may be broken." fi @@ -235,7 +264,14 @@ fi # a hard gate so a broken gbrain is caught at setup, not at data-loss time. # Pre-init installs skip this (config not written yet); the full # `/sync-gbrain --dry-run` self-test runs from /setup-gbrain after `gbrain init`. -_GBRAIN_HOME_CHECK="${GBRAIN_HOME:-$HOME/.gbrain}" +# #2521: GBRAIN_HOME is a PARENT dir per gbrain's configDir() contract — +# gbrain appends `.gbrain` itself, so the config lives at +# $GBRAIN_HOME/.gbrain/config.json (or ~/.gbrain/config.json when unset). +if [ -n "${GBRAIN_HOME:-}" ]; then + _GBRAIN_HOME_CHECK="$GBRAIN_HOME/.gbrain" +else + _GBRAIN_HOME_CHECK="$HOME/.gbrain" +fi if [ -f "$_GBRAIN_HOME_CHECK/config.json" ]; then if ! gbrain doctor --fast >/dev/null 2>&1; then echo "" >&2 diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 4cf6709df8..4e3034b3a7 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -29,7 +29,7 @@ * than building a gstack-side daemon. */ -import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync } from "fs"; +import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync, realpathSync } from "fs"; import { join, dirname } from "path"; import { execSync, spawnSync } from "child_process"; import { homedir, hostname } from "os"; @@ -41,7 +41,7 @@ import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleComplet import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards"; import { writeReceipt } from "../lib/egress-receipt"; import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status"; -import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec"; +import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS, bashScriptInvocation } from "../lib/gbrain-exec"; import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client"; import { checkOwnedStagingDir } from "../lib/staging-guard"; @@ -368,6 +368,42 @@ function deriveCodeSourceId(repoPath: string): string { return constrainSourceId("gstack-code", `${base}-${hostPathHash}`); } +/** + * Reuse an explicit repo pin when it names a registered source for this exact + * checkout. The path check prevents a stale or copied dotfile from redirecting + * a code sync into another repo's source. + */ +function readPinnedSourceId(repoPath: string): string | null { + const pinPath = join(repoPath, ".gbrain-source"); + if (!existsSync(pinPath)) return null; + + try { + const sourceId = readFileSync(pinPath, "utf-8").trim(); + return /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(sourceId) ? sourceId : null; + } catch { + // A pin is advisory. A permission race or a directory at this path must + // not turn a sync preview into an unexpected crash. + return null; + } +} + +export function existingPinnedSourceId(repoPath: string, env?: NodeJS.ProcessEnv): string | null { + const sourceId = readPinnedSourceId(repoPath); + if (!sourceId) return null; + + const registeredPath = sourceLocalPath(sourceId, env); + if (!registeredPath) return null; + try { + return realpathSync(registeredPath) === realpathSync(repoPath) ? sourceId : null; + } catch { + return null; + } +} + +function resolveCodeSourceId(repoPath: string, env?: NodeJS.ProcessEnv): string { + return existingPinnedSourceId(repoPath, env) ?? deriveCodeSourceId(repoPath); +} + /** * Pre-pathhash source id, kept for orphan detection only. * @@ -820,7 +856,13 @@ async function runCodeImport(args: CliArgs): Promise { return { name: "code", ran: false, ok: true, duration_ms: 0, summary: "skipped (not in git repo)" }; } - const sourceId = deriveCodeSourceId(root); + // A preview must not spawn gbrain. Trust a syntactically-valid local pin + // there; a real run confirms its registered path before using it. + const gbrainEnv = args.mode === "dry-run" ? undefined : buildGbrainEnv({ announce: !args.quiet }); + const pinnedSourceId = args.mode === "dry-run" + ? readPinnedSourceId(root) + : existingPinnedSourceId(root, gbrainEnv); + const sourceId = pinnedSourceId ?? deriveCodeSourceId(root); // Per-repo trust tier — checked BEFORE the dry-run branch so previews report // the refusal honestly instead of claiming they would sync. @@ -861,7 +903,9 @@ async function runCodeImport(args: CliArgs): Promise { ran: false, ok: true, duration_ms: 0, - summary: `would: gbrain sources add ${sourceId} --path ${root} --federated; gbrain sync --strategy code --source ${sourceId}; gbrain sources attach ${sourceId}`, + summary: pinnedSourceId + ? `would: gbrain sync --strategy code --source ${sourceId}; gbrain sources attach ${sourceId}` + : `would: gbrain sources add ${sourceId} --path ${root} --federated; gbrain sync --strategy code --source ${sourceId}; gbrain sources attach ${sourceId}`, detail: { source_id: sourceId, source_path: root, status: "skipped" }, }; } @@ -889,10 +933,9 @@ async function runCodeImport(args: CliArgs): Promise { // gbrainEnv seeds DATABASE_URL from gbrain's config so this stage works // inside Next.js / Prisma / Rails projects with their own .env.local // (codex review #7 — bug fix is wider than #1508 as filed). - const gbrainEnv = buildGbrainEnv({ announce: !args.quiet }); const legacyId = deriveLegacyCodeSourceId(root); let legacyRemoved = false; - if (legacyId !== sourceId) { + if (!pinnedSourceId && legacyId !== sourceId) { // #1734: route through the data-loss guards (autopilot + source-safety). const rm = safeSourcesRemove(legacyId, gbrainEnv); if (rm.skipped && !args.quiet) { @@ -908,7 +951,9 @@ async function runCodeImport(args: CliArgs): Promise { // pages); fall back to register-new → sync-OK → remove-old. Path-drift // (user moved the repo, etc.) skips migration with a warning. const pathOnlyHashLegacyId = derivePathOnlyHashLegacyId(root); - const migration = planHostnameFoldMigration(root, sourceId, pathOnlyHashLegacyId, gbrainEnv); + const migration = pinnedSourceId + ? { kind: "none", reason: "no-legacy-source" } as const + : planHostnameFoldMigration(root, sourceId, pathOnlyHashLegacyId, gbrainEnv); if (migration.kind === "skipped-path-drift" && !args.quiet) { console.error( `[sync:code] hostname-fold migration skipped: legacy source ${migration.oldId} ` @@ -919,21 +964,24 @@ async function runCodeImport(args: CliArgs): Promise { console.error(`[sync:code] hostname-fold migration: renamed ${migration.oldId} → ${migration.newId} (pages preserved)`); } - // Step 1: Ensure source registered (idempotent). Single source of truth in lib — - // no synchronous duplicate here (per /codex review #12). + // Step 1: Ensure generated sources are registered. A confirmed explicit pin + // belongs to the user: its realpath was checked above, so never remove/add it + // merely because the registered spelling differs (e.g. a symlinked checkout). let registered = false; - try { - const result = await ensureSourceRegistered(sourceId, root, { federated: true, env: gbrainEnv }); - registered = result.changed; - } catch (err) { - return { - name: "code", - ran: true, - ok: false, - duration_ms: Date.now() - t0, - summary: `source registration failed: ${(err as Error).message}`, - detail: { source_id: sourceId, source_path: root, status: "failed" }, - }; + if (!pinnedSourceId) { + try { + const result = await ensureSourceRegistered(sourceId, root, { federated: true, env: gbrainEnv }); + registered = result.changed; + } catch (err) { + return { + name: "code", + ran: true, + ok: false, + duration_ms: Date.now() - t0, + summary: `source registration failed: ${(err as Error).message}`, + detail: { source_id: sourceId, source_path: root, status: "failed" }, + }; + } } // Step 2: Always run the page-creating file walk first, then (for --full) @@ -995,7 +1043,25 @@ async function runCodeImport(args: CliArgs): Promise { }; } - const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], { + // `--full` must do a FULL walk, not a delta one. + // + // A bare `sync --strategy code` is incremental: it only revisits files that + // changed since the source's checkpoint. So a file missed at the ORIGINAL + // import is never revisited and stays invisible indefinitely — and the + // reindex-code pass below cannot rescue it, because it re-chunks pages that + // already exist and never walks the filesystem (the same property the comment + // above already relies on). + // + // The failure is silent: no error, no warning, and the verdict block still + // reports OK while `gbrain search` and `gbrain code-def` answer out of a + // partial index. It presents as "gbrain is weak at code questions" rather + // than "the index is incomplete", which is what makes it hard to spot. + // + // --yes because this is spawned non-interactively; a full walk otherwise + // prompts to confirm the import cost. + const walkArgs = ["sync", "--strategy", "code", "--source", sourceId]; + if (args.mode === "full") walkArgs.push("--full", "--yes"); + const walkResult = spawnGbrain(walkArgs, { stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], timeout: codeTimeoutMs, baseEnv: gbrainEnv, @@ -1007,7 +1073,7 @@ async function runCodeImport(args: CliArgs): Promise { ran: true, ok: false, duration_ms: Date.now() - t0, - summary: `gbrain sync --strategy code --source ${sourceId} exited ${walkResult.status}`, + summary: `gbrain ${walkArgs.join(" ")} exited ${walkResult.status}`, detail: { source_id: sourceId, source_path: root, status: "failed" }, }; } @@ -1245,18 +1311,31 @@ function runBrainSyncPush(args: CliArgs): StageResult { return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "skipped (gstack-brain-sync not installed)" }; } - // #1731: gstack-brain-sync is a bash shebang script; Windows can't spawn it - // without a shell, which surfaced as "brain-sync exited undefined". - spawnSync(brainSyncPath, ["--discover-new"], { - stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], - timeout: 60 * 1000, - shell: NEEDS_SHELL_ON_WINDOWS, - }); - const result = spawnSync(brainSyncPath, ["--once"], { - stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], - timeout: 60 * 1000, - shell: NEEDS_SHELL_ON_WINDOWS, - }); + // gstack-brain-sync is a bash shebang script, so it needs an INTERPRETER, not + // a shell. #1731 gave it `shell: NEEDS_SHELL_ON_WINDOWS`, which is right for + // the gbrain.cmd shim and useless here: cmd.exe resolves .cmd/.bat via PATHEXT + // and rejects an extension-less shebang script outright ("is not recognized as + // an internal or external command"), so this stage failed on EVERY Windows run + // while looking like a single red line in an otherwise green report. See + // bashScriptInvocation. + const discover = bashScriptInvocation(brainSyncPath, ["--discover-new"]); + const once = bashScriptInvocation(brainSyncPath, ["--once"]); + if (!discover || !once) { + return { + name: "brain-sync", + ran: false, + ok: true, + duration_ms: Date.now() - t0, + summary: "skipped (no bash found; set GSTACK_BASH to your Git bash.exe)", + }; + } + + const stdio: "ignore"[] | ("ignore" | "inherit")[] = args.quiet + ? ["ignore", "ignore", "ignore"] + : ["ignore", "inherit", "inherit"]; + + spawnSync(discover.cmd, discover.argv, { stdio, timeout: 60 * 1000, shell: discover.shell }); + const result = spawnSync(once.cmd, once.argv, { stdio, timeout: 60 * 1000, shell: once.shell }); return { name: "brain-sync", @@ -1305,7 +1384,7 @@ export async function runDream(args: CliArgs): Promise { if (args.mode === "dry-run") { const root = repoRoot(); - const sourceId = root ? deriveCodeSourceId(root) : null; + const sourceId = root ? readPinnedSourceId(root) ?? deriveCodeSourceId(root) : null; return { name: "dream", ran: false, @@ -1317,6 +1396,7 @@ export async function runDream(args: CliArgs): Promise { }; } + const gbrainEnv = buildGbrainEnv({ announce: !args.quiet }); const localStatus = localEngineStatus({ noCache: false }); if (localStatus === "timeout") { warnProbeTimeout("dream"); // #1964: slow-but-healthy — proceed @@ -1352,7 +1432,7 @@ export async function runDream(args: CliArgs): Promise { // code-callers/code-callees for this worktree. Falls back to plain `dream` // only when we can't derive the source id (not in a git repo). const root = repoRoot(); - const sourceId = root ? deriveCodeSourceId(root) : null; + const sourceId = root ? resolveCodeSourceId(root, gbrainEnv) : null; const dreamArgs = sourceId ? ["dream", "--source", sourceId] : ["dream"]; // spawnGbrain seeds DATABASE_URL from gbrain's config via buildGbrainEnv. @@ -1481,7 +1561,14 @@ export function parseResolvedEdges(out: string): number | null { export function classifyDreamOutcome(out: string): string | null { // The active schema pack doesn't declare the code-symbol extraction phase, so // no symbols are extracted and resolve_symbol_edges has nothing to match. - if (/does not declare this phase/i.test(out)) { + // #2341: anchor the match to a GRAPH phase. The bare phrase false-positived + // on every base-pack brain — gbrain's only emitters of "active pack does not + // declare this phase" are the CONTENT phases (extract_atoms, + // synthesize_concepts), which base packs legitimately skip while + // resolve_symbol_edges still runs and builds the graph. Matching the bare + // phrase sent users pack-churning ("switch schema packs") for nothing and + // masked real graph bugs behind a wrong diagnosis. + if (/(resolve_symbol_edges|extract_code_symbols)[^\n]*does not declare/i.test(out)) { return ( "dream ran, but this source's schema pack does not extract code symbols, " + "so the call graph stays empty. Switch this source to a code-aware schema " + @@ -1644,7 +1731,8 @@ async function main(): Promise { let cycle: CycleStatus | null = null; if (!args.dream && args.mode === "full" && !args.noDream && !args.noCode) { const root = repoRoot(); - cycle = root ? cycleCompleted(deriveCodeSourceId(root), process.env) : "unknown"; + const gbrainEnv = buildGbrainEnv({ announce: !args.quiet }); + cycle = root ? cycleCompleted(resolveCodeSourceId(root, gbrainEnv), gbrainEnv) : "unknown"; } if (shouldRunDream(args, cycle)) { dreamStage = await runDream(args); diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index 4aeba0b69e..5cbb535baf 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -543,7 +543,7 @@ interface ParsedSession { partial: boolean; } -function parseTranscriptJsonl(path: string): ParsedSession | null { +export function parseTranscriptJsonl(path: string): ParsedSession | null { // Best-effort tolerant parser. Handles truncated last lines (D10 partial-flag). let raw: string; try { @@ -619,7 +619,7 @@ function parseTranscriptJsonl(path: string): ParsedSession | null { const tool = rec?.name || rec?.tool || rec?.tool_call?.name || "tool"; bodyParts.push(`### Tool call: ${tool}`); } else if (isCodex && rec?.payload?.message) { - // Codex shape: each record has payload.message + // Legacy Codex shape: each record has payload.message const msg = rec.payload.message; const role = msg.role || "user"; const content = extractContentText(msg); @@ -627,6 +627,18 @@ function parseTranscriptJsonl(path: string): ParsedSession | null { bodyParts.push(`## ${role.charAt(0).toUpperCase() + role.slice(1)}\n\n${content}`); messageCount++; } + } else if (isCodex && rec?.type === "response_item" && rec?.payload?.type === "message") { + // Current Codex rollout shape (#2105): records are + // { type: 'response_item', payload: { type: 'message', role, content: [...] } }. + // The legacy payload.message branch never fires on these, which rendered + // every Codex session as an empty shell (message_count: 0, 243/243 on + // the reporting machine). Flatten payload.content like the Claude branch. + const role = rec.payload.role || "user"; + const content = extractContentText(rec.payload); + if (content) { + bodyParts.push(`## ${role.charAt(0).toUpperCase() + role.slice(1)}\n\n${content}`); + messageCount++; + } } } diff --git a/bin/gstack-next-version b/bin/gstack-next-version index 76879ddbd2..e197815947 100755 --- a/bin/gstack-next-version +++ b/bin/gstack-next-version @@ -19,6 +19,13 @@ // committed so all collaborators benefit) // 3. "VERSION" at the repo root (default, backward-compatible) // +// The pinned path may be a package.json (any depth) rather than a plain-text +// VERSION file: a path ending in .json is read as JSON and its .version taken. +// 3-digit semver is accepted as well as 4-digit, and stays 3-digit through +// bumping. See lib/version-source.ts for why both mattered — each used to fail +// closed, which silently disabled the queue-collision check this CLI exists to +// provide (#2501). +// // Exit codes: // 0 — emitted JSON successfully (may include "offline":true or "host":"unknown") // 2 — invalid arguments @@ -28,9 +35,18 @@ import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; - -type Bump = "major" | "minor" | "patch" | "micro"; -type Version = [number, number, number, number]; +import { + parseVersion, + versionWidth, + fmtVersion, + bumpVersion, + cmpVersion, + bumpWasCoerced, + extractVersion, + type Bump, + type Version, + type VersionWidth, +} from "../lib/version-source"; type ClaimedPR = { pr: number; @@ -56,6 +72,7 @@ type Output = { bump: Bump; host: "github" | "gitlab" | "unknown"; offline: boolean; + fallback: "git" | null; claimed: ClaimedPR[]; siblings: Sibling[]; active_siblings: Sibling[]; @@ -66,48 +83,20 @@ type Output = { const ACTIVE_SIBLING_MAX_AGE_S = 24 * 60 * 60; const GH_API_CONCURRENCY = 10; -function parseVersion(s: string): Version | null { - const m = s.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); - if (!m) return null; - return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]; -} - -function fmtVersion(v: Version): string { - return v.join("."); -} - -function bumpVersion(v: Version, level: Bump): Version { - switch (level) { - case "major": - return [v[0] + 1, 0, 0, 0]; - case "minor": - return [v[0], v[1] + 1, 0, 0]; - case "patch": - return [v[0], v[1], v[2] + 1, 0]; - case "micro": - return [v[0], v[1], v[2], v[3] + 1]; - } -} - -function cmpVersion(a: Version, b: Version): number { - for (let i = 0; i < 4; i++) { - if (a[i] !== b[i]) return a[i] - b[i]; - } - return 0; -} - // Collision resolution: bump past the highest claimed within the same level. // Semantics: if my bump is MINOR and the queue claims 1.7.0.0, I advance to // 1.8.0.0 (still a MINOR relative to main). Preserves ship-time intent. -function pickNextSlot(base: Version, claimed: Version[], level: Bump): { version: Version; reason: string } { - let candidate = bumpVersion(base, level); +// `width` keeps a 3-digit repo 3-digit (see lib/version-source.ts); it +// defaults to 4 so existing callers and tests are unaffected. +function pickNextSlot(base: Version, claimed: Version[], level: Bump, width: VersionWidth = 4): { version: Version; reason: string } { + let candidate = bumpVersion(base, level, width); const sortedClaimed = [...claimed].sort(cmpVersion); const highest = sortedClaimed[sortedClaimed.length - 1]; if (highest && cmpVersion(highest, base) > 0) { // Queue already advanced past base; bump past the highest claim. - const bumpedPastHighest = bumpVersion(highest, level); + const bumpedPastHighest = bumpVersion(highest, level, width); if (cmpVersion(bumpedPastHighest, candidate) > 0) { - return { version: bumpedPastHighest, reason: `bumped past claimed ${fmtVersion(highest)}` }; + return { version: bumpedPastHighest, reason: `bumped past claimed ${fmtVersion(highest, width)}` }; } } return { version: candidate, reason: "no collision; clean bump from base" }; @@ -167,7 +156,12 @@ function readBaseVersion(base: string, versionPath: string, warnings: string[]): warnings.push(`could not read ${versionPath} at origin/${base}; assuming 0.0.0.0`); return "0.0.0.0"; } - return r.stdout.trim(); + const v = extractVersion(r.stdout, versionPath); + if (!v) { + warnings.push(`${versionPath} at origin/${base} has no readable version; assuming 0.0.0.0`); + return "0.0.0.0"; + } + return v; } async function fetchGithubClaimed(base: string, versionPath: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> { @@ -233,7 +227,7 @@ async function fetchGithubClaimed(base: string, versionPath: string, excludePR: } let versionStr: string; try { - versionStr = Buffer.from(content.stdout.trim(), "base64").toString("utf8").trim(); + versionStr = extractVersion(Buffer.from(content.stdout.trim(), "base64").toString("utf8"), versionPath); } catch { warnings.push(`PR #${pr.number}: VERSION is not valid base64`); continue; @@ -290,7 +284,7 @@ async function fetchGitlabClaimed(base: string, versionPath: string, excludePR: } try { const j = JSON.parse(content.stdout); - const versionStr = Buffer.from(j.content, "base64").toString("utf8").trim(); + const versionStr = extractVersion(Buffer.from(j.content, "base64").toString("utf8"), versionPath); if (!parseVersion(versionStr)) { warnings.push(`MR !${mr.iid}: VERSION malformed (${versionStr})`); continue; @@ -349,7 +343,7 @@ function scanSiblings(root: string | null, versionPath: string, claimed: Claimed if (!existsSync(versionFile)) continue; let version: string; try { - version = readFileSync(versionFile, "utf8").trim(); + version = extractVersion(readFileSync(versionFile, "utf8"), versionPath); if (!parseVersion(version)) continue; } catch { continue; @@ -452,6 +446,84 @@ function autoDetectExcludePR(): number | null { return Number.isFinite(n) && n > 0 ? n : null; } +// ── git-only fallback (#2545) ──────────────────────────────────────────── +// +// When the host query fails this util used to return `offline:true` with an +// EMPTY claim set, and /ship's instruction was "fall back to local BUMP_LEVEL +// arithmetic". Local arithmetic cannot see a sibling's claim, so the fallback +// allocated a version another open PR already held. +// +// That is not hypothetical. On 2026-08-12 in a downstream repo, `gh pr list` +// failed during a ship, this util reported offline, the bump fell back to +// local arithmetic, and 0.1.57.0 was allocated to a second PR while an open +// one already claimed it — both merged, and main carries two commits reading +// v0.1.57.0. Auditing that repo's history found FOUR such pairs going back +// three weeks, so the silent fallback had been mis-allocating for a while. +// +// Git already knows what the API was asked for. Remote-tracking refs carry +// each branch's VERSION file, and the base's own history records every version +// already shipped. Neither needs a token, a network round-trip, or a working +// `gh`. So "offline" degrades the QUEUE VIEW (no PR numbers, no draft status) +// without degrading the ALLOCATION. +function fetchGitClaimed( + base: string, + versionPath: string, + warnings: string[], +): ClaimedPR[] { + const claims: ClaimedPR[] = []; + + // 1. Every remote-tracking branch's VERSION file. These are the open PRs' + // branches, whether or not the API can be reached to enumerate them. + // Read through extractVersion so a JSON version-path (#2501) resolves on + // remote refs too, and the branch's own width is preserved in the claim. + const refs = runCommand("git", [ + "for-each-ref", + "--format=%(refname:short)", + "refs/remotes", + ]); + if (refs.ok) { + const baseShort = base.replace(/^origin\//, ""); + for (const ref of refs.stdout.split("\n").map((r) => r.trim()).filter(Boolean)) { + if (ref.endsWith("/HEAD")) continue; + if (ref === base || ref.replace(/^origin\//, "") === baseShort) continue; + const show = runCommand("git", ["show", `${ref}:${versionPath}`]); + if (!show.ok) continue; + const raw = extractVersion(show.stdout, versionPath); + if (!raw || !parseVersion(raw)) continue; + claims.push({ pr: 0, branch: ref, version: raw }); + } + } else { + warnings.push("git for-each-ref failed; branch claims unavailable"); + } + + // 2. Versions already shipped, read from the base's commit subjects. Catches + // the case the VERSION file cannot: a number that merged and was then + // re-picked. Bounded, and it says so rather than implying full history. + const SUBJECT_SCAN = 400; + const log = runCommand("git", ["log", `-n${SUBJECT_SCAN}`, "--format=%s", base]); + if (log.ok) { + for (const subject of log.stdout.split("\n")) { + const m = subject.trim().match(/^v(\d+\.\d+\.\d+(?:\.\d+)?)\b/); + if (!m) continue; + if (!parseVersion(m[1])) continue; + claims.push({ pr: 0, branch: `(shipped on ${base})`, version: m[1] }); + } + // A cap that does not announce itself reads as "checked all history". + // Only fires when the log came back exactly full, which is the only + // observable signal that older commits went unread. + if (log.stdout.trim().split("\n").length >= SUBJECT_SCAN) { + warnings.push( + `shipped-version scan stopped at ${SUBJECT_SCAN} commits on ${base}; ` + + `a version shipped before that is not counted as claimed`, + ); + } + } else { + warnings.push(`git log ${base} failed; shipped-version scan unavailable`); + } + + return claims; +} + async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { @@ -469,6 +541,13 @@ async function main() { console.error(`Error: could not parse base version '${baseVersion}'`); process.exit(2); } + // The repo's own width governs everything downstream: a 3-digit repo must + // not be handed a 4-digit slot, or /ship writes a version the repo's tooling + // can't read back (#2501). + const width = versionWidth(baseVersion); + if (bumpWasCoerced(args.bump, width)) { + warnings.push(`--bump micro has no component to move in a ${width}-digit version; treated as patch`); + } const excludePR = args.excludePR ?? autoDetectExcludePR(); if (excludePR !== null && args.excludePR === null) { @@ -485,6 +564,28 @@ async function main() { warnings.push("host unknown; queue-awareness unavailable"); } + // Degraded host query → fall back to git, which needs no API. Additive: it + // only runs when the host told us nothing, so the online path is untouched. + let fallback: "git" | null = null; + if (offline || host === "unknown") { + const gitClaims = fetchGitClaimed(args.base, versionPath, warnings); + if (gitClaims.length) { + claimed = [...claimed, ...gitClaims]; + fallback = "git"; + warnings.push( + `host queue unavailable — allocated from git instead ` + + `(${gitClaims.length} claim(s) from remote refs + shipped subjects). ` + + `PR numbers and draft status are unavailable, but the version is safe.`, + ); + } else { + warnings.push( + "host queue unavailable AND git found no claims — the pick rests on " + + "the base VERSION alone. Verify no sibling branch holds it before " + + "shipping.", + ); + } + } + // Only count PRs that actually bumped VERSION past base as real "claims". // A PR whose VERSION equals base's VERSION hasn't claimed anything. const realClaims = claimed.filter((c) => { @@ -495,7 +596,7 @@ async function main() { .map((c) => parseVersion(c.version)) .filter((v): v is Version => v !== null); - const { version: picked, reason } = pickNextSlot(baseParsed, claimedVersions, args.bump); + const { version: picked, reason } = pickNextSlot(baseParsed, claimedVersions, args.bump, width); const workspaceRoot = resolveWorkspaceRoot(args.workspaceRoot); const siblings = markActiveSiblings(scanSiblings(workspaceRoot, versionPath, claimed, warnings), baseParsed); @@ -510,18 +611,19 @@ async function main() { .filter((v) => cmpVersion(v, finalVersion) >= 0); if (activeAhead.length) { const highest = activeAhead.sort(cmpVersion)[activeAhead.length - 1]; - finalVersion = bumpVersion(highest, args.bump); - finalReason = `bumped past active sibling ${fmtVersion(highest)}`; + finalVersion = bumpVersion(highest, args.bump, width); + finalReason = `bumped past active sibling ${fmtVersion(highest, width)}`; } const out: Output = { - version: fmtVersion(finalVersion), + version: fmtVersion(finalVersion, width), current_version: args.current || baseVersion, base_version: baseVersion, version_path: versionPath, bump: args.bump, host, offline, + fallback, claimed: realClaims, siblings, active_siblings: activeSiblings, @@ -531,8 +633,11 @@ async function main() { process.stdout.write(JSON.stringify(out, null, 2) + "\n"); } -// Pure-function exports for testing -export { parseVersion, fmtVersion, bumpVersion, cmpVersion, pickNextSlot, markActiveSiblings, resolveVersionPath }; +// Pure-function exports for testing. The version primitives are re-exported +// from lib/version-source so existing importers of this module keep working +// unchanged. +export { parseVersion, fmtVersion, bumpVersion, cmpVersion, versionWidth, extractVersion }; +export { pickNextSlot, markActiveSiblings, resolveVersionPath, fetchGitClaimed }; // Only run main() when invoked as a script, not when imported by tests. if (import.meta.main) { diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush index d4fe450004..1dac0e94e0 100755 --- a/bin/gstack-redact-prepush +++ b/bin/gstack-redact-prepush @@ -70,6 +70,33 @@ function objectExists(sha: string): boolean { return r.status === 0; } +/** + * The remote-tracking exclusion used when narrowing to "commits new to the + * remote" (#2592 catch-up merges, #2573 rebased force-pushes). + * + * Narrowed to the PUSH TARGET's namespace (S1): a bare `--remotes` excludes + * commits reachable from ANY remote-tracking ref, so a secret that had only + * ever been fetched from (or pushed to) a private/local-path remote was never + * scanned when later pushed to a PUBLIC remote — "already left this machine" + * is not "already reached THIS remote". Git hands pre-push the push remote's + * name as $1 (and its URL as $2); the installed hook wrapper forwards "$@". + * Fallbacks keep the historical all-remotes behavior when the name is + * unavailable (stdin/CLI invocation) or is not a configured remote (URL + * pushes have no remote-tracking namespace) — falling back scans LESS than + * the narrowed form would, but never less than the hook historically did. + */ +let _remotesExclusion: string | undefined; +function remotesExclusion(): string { + if (_remotesExclusion === undefined) { + const name = process.argv[2]; + const configured = name + ? git(["remote"]).split("\n").map((s) => s.trim()).filter(Boolean).includes(name) + : false; + _remotesExclusion = configured ? `--remotes=${name}/*` : "--remotes"; + } + return _remotesExclusion; +} + function defaultRemoteBranch(): string { // origin/HEAD → origin/main, fall back to main/master. const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"]).trim(); @@ -104,12 +131,12 @@ function unknownRemoteTipBase(localSha: string): string | null { // engine's byte cap, so `engine.input_too_large` blocks having scanned // NOTHING — "scans more, never less" inverted into "scans nothing". // - // `--remotes` covers every remote, not just the push target: content - // already published anywhere has already left this machine, so treating it - // as pre-existing is deliberate. Git hands the remote name to pre-push in - // argv, which this hook does not read; narrowing to it would only matter - // for a repo that pushes secrets to one remote but not another. - const newCommits = git(["rev-list", "--reverse", localSha, "--not", "--remotes"]).trim(); + // The exclusion is scoped to the PUSH TARGET's tracking refs (see + // remotesExclusion): content on some OTHER remote has left this machine, + // but it has not reached the remote being pushed to — a secret that only + // ever hit a private remote must still be scanned on its way to a public + // one (S1). + const newCommits = git(["rev-list", "--reverse", localSha, "--not", remotesExclusion()]).trim(); if (newCommits) { const oldest = newCommits.split("\n")[0]; const parent = git(["rev-parse", "--verify", `${oldest}^`]).trim(); @@ -122,8 +149,90 @@ function unknownRemoteTipBase(localSha: string): string | null { return null; } +/** + * The commits this push actually adds — reachable from localSha and from NO + * remote-tracking ref. + * + * ⚠ WHY THIS EXISTS RATHER THAN A TWO-DOT RANGE. + * + * `remoteSha..localSha` is "everything new on this branch", which is NOT the + * same as "everything new to the remote". Merge origin/main into a feature + * branch and every commit main gained since the branch's last push becomes an + * added line — content that is already published, already scanned, and not + * this push's doing. + * + * Two things follow, and both were observed: + * + * · FALSE HIGH FINDINGS. A placeholder connection string in a test fixture, + * already merged to main by someone else, blocked an unrelated push as + * `db.url_with_password` — telling the operator to rotate a credential + * over a fixture they had never touched. A + * guard that cries wolf on catch-up merges is a guard people learn to + * bypass reflexively — which is exactly how a real secret gets through. + * · OVERSIZED SCANS. The comment on SCAN_CHUNK_BYTES below records a + * 1,146,782-byte diff from "a feature branch catching up to a busy main" + * blowing the engine's 1 MiB cap. Same root cause, treated there as a size + * problem and solved by slicing. Narrowing the range fixes the size too. + * + * A two-dot range cannot express this: after merging main, neither the remote + * tip nor the merge-base with main is an ancestor of the other, so no single + * base excludes both. `rev-list --not --remotes=/*` is the + * operation that does, and this file already reasons that way in + * `unknownRemoteTipBase` step 2. The exclusion is scoped to the push target's + * tracking namespace (see remotesExclusion): the upstream commits a catch-up + * merge brings in came from the SAME remote being pushed to, so scoping keeps + * the #2592 fix intact while a secret known only to some OTHER (private) + * remote is still scanned on its way to this one (S1). + * + * Each commit is diffed alone. `--cc` on a merge shows only the conflict + * RESOLUTION — content that exists in no parent — so a secret introduced while + * resolving a merge is still caught, while an ordinary merge contributes + * nothing. Returns null when the notion does not apply, so callers fall back. + */ +function addedLinesFromNewCommits(localSha: string, remoteSha: string): string | null { + // remoteSha is what git TELLS us the remote has, and it is authoritative in a + // way `--remotes` is not: remote-tracking refs can be absent (a fresh clone + // that never fetched, a push to a remote with no tracking ref) or stale. Drop + // it and a repo with no tracking refs excludes NOTHING — every commit ever + // made reads as "new", which re-introduces the false positives from the other + // direction. So it stays the base; `--remotes` only ADDS exclusions on top. + if (ZERO.test(remoteSha) || !objectExists(remoteSha)) return null; + + const narrowed = git(["rev-list", localSha, "--not", remoteSha, remotesExclusion()]).trim(); + if (!narrowed) return null; + + // If excluding remote-tracking refs changes nothing, this push has no + // catch-up commits and the plain range already describes it exactly. Defer to + // it. That is not just an optimization: it keeps every push that ISN'T a + // catch-up merge on the original gitStrict diff path, so the fail-closed + // guarantee (#1946) and its regression test keep exercising the code they + // were written for. A narrowing that silently retired that test would be a + // worse trade than the false positives it set out to fix. + const plain = git(["rev-list", `${remoteSha}..${localSha}`]).trim(); + const asSet = (s: string) => s.split("\n").filter(Boolean).sort().join("\n"); + if (asSet(narrowed) === asSet(plain)) return null; + + const shas = narrowed.split("\n").filter(Boolean); + // A rewrite of long history should fall back rather than shell out per commit. + if (shas.length > 500) return null; + const out: string[] = []; + for (const sha of shas) { + // gitStrict: a failed diff must never read as "nothing added" (#1946). + out.push(gitStrict([ + "show", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv", + "--cc", "--format=", sha, + ])); + } + return out.join("\n"); +} + /** Return the added-line text for a ref update being pushed. */ function addedLinesFor(localSha: string, remoteSha: string): string { + // Preferred ONLY when this push carries catch-up commits: scanning them again + // is the bug. Every other shape falls through to the range logic below. + const fromNew = addedLinesFromNewCommits(localSha, remoteSha); + if (fromNew !== null) return collectAddedLines(fromNew); + let range: string; if (ZERO.test(remoteSha) || !objectExists(remoteSha)) { // Either a new branch (zero remote sha), or the remote tip object is absent @@ -151,6 +260,14 @@ function addedLinesFor(localSha: string, remoteSha: string): string { "diff", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv", range, ]); + return collectAddedLines(diff); +} + +/** + * Added-line text from a unified diff. Shared by both range strategies so the + * hunk-aware header handling below cannot drift between them. + */ +function collectAddedLines(diff: string): string { const added: string[] = []; // Hunk-aware header skip (#2498): `+++ ` is only a FILE HEADER outside a // hunk. Inside a hunk, an added content line whose text begins with "++" @@ -158,7 +275,11 @@ function addedLinesFor(localSha: string, remoteSha: string): string { // silently dropped exactly those lines from the scan. let inHunk = false; for (const line of diff.split("\n")) { - if (line.startsWith("diff --git")) { inHunk = false; continue; } + // `diff --` rather than `diff --git`: a merge scanned with --cc emits + // `diff --cc `, so a --git-only reset left inHunk true across file + // boundaries and read the next file's `+++ b/...` header as content. Only + // noise (it over-scans, never under-scans), but the boundary is real. + if (line.startsWith("diff --")) { inHunk = false; continue; } if (line.startsWith("@@")) { inHunk = true; continue; } if (!inHunk && (line.startsWith("+++") || line.startsWith("---"))) continue; if (line.startsWith("+")) added.push(line.slice(1)); diff --git a/bin/gstack-relink b/bin/gstack-relink index dd2a681fa4..e87bc3fac1 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -36,6 +36,12 @@ SKILLS_DIR="${GSTACK_SKILLS_DIR:-$(dirname "$INSTALL_DIR")}" # Read prefix setting PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false") +# #2569: rendered :user variants (brain-aware blocks) live in an UNTRACKED +# out-dir instead of the tracked install checkout. When a render exists for a +# skill, relink serves it — otherwise a config change would silently flip +# every skill back to the canonical (blockless) source. +RENDER_DIR="${GSTACK_USER_RENDER_DIR:-${GSTACK_HOME:-$HOME/.gstack}/render/claude}" + # Helper: remove old skill entry (symlink or real directory with symlinked SKILL.md) _cleanup_skill_entry() { local entry="$1" @@ -52,7 +58,13 @@ _link_root_skill_alias() { [ -f "$INSTALL_DIR/SKILL.md" ] || return 0 [ -L "$target" ] && rm -f "$target" mkdir -p "$target" - ln -snf "$INSTALL_DIR/SKILL.md" "$target/SKILL.md" + # Copy-then-rewrite, never a symlink (#2511): a symlinked alias re-serves + # the canonical `name: gstack`, Claude Code sees a duplicate skill name, + # and drops the ENTIRE personal-skills set. sed reads the source and writes + # a fresh copy — remove any prior symlink first so the redirect can never + # write through it into the generated source. + rm -f "$target/SKILL.md" + sed "1,/^---\$/ s/^name:[[:space:]].*/name: _gstack-command/" "$INSTALL_DIR/SKILL.md" > "$target/SKILL.md" } _link_root_skill_alias @@ -61,6 +73,11 @@ _link_root_skill_alias SKILL_COUNT=0 for skill_dir in "$INSTALL_DIR"/*/; do [ -d "$skill_dir" ] || continue + # Skip symlinked skill dirs (connect-chrome → open-gstack-browser): linking + # one under the symlink's basename would duplicate the canonical frontmatter + # name and collide in Claude Code's skill registry (#2201). setup owns the + # rewritten-copy alias for those. + [ -L "${skill_dir%/}" ] && continue skill=$(basename "$skill_dir") # Skip non-skill directories case "$skill" in bin|browse|design|docs|extension|lib|node_modules|scripts|test|.git|.github) continue ;; esac @@ -87,7 +104,9 @@ for skill_dir in "$INSTALL_DIR"/*/; do [ -L "$target" ] && rm -f "$target" # Create real directory with symlinked SKILL.md (absolute path) mkdir -p "$target" - ln -snf "$INSTALL_DIR/$skill/SKILL.md" "$target/SKILL.md" + skill_md_src="$INSTALL_DIR/$skill/SKILL.md" + [ -f "$RENDER_DIR/$skill/SKILL.md" ] && skill_md_src="$RENDER_DIR/$skill/SKILL.md" + ln -snf "$skill_md_src" "$target/SKILL.md" SKILL_COUNT=$((SKILL_COUNT + 1)) done diff --git a/bin/gstack-repo-mode b/bin/gstack-repo-mode index 0aabe378e7..a5c5a6ba64 100755 --- a/bin/gstack-repo-mode +++ b/bin/gstack-repo-mode @@ -44,7 +44,15 @@ fi CACHE_DIR="$HOME/.gstack/projects/$SLUG" CACHE_FILE="$CACHE_DIR/repo-mode.json" if [ -f "$CACHE_FILE" ]; then - CACHE_AGE=$(( $(date +%s) - $(stat -f %m "$CACHE_FILE" 2>/dev/null || stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0) )) + # GNU first (#2195): on GNU coreutils `stat -f` SUCCEEDS with filesystem + # status (not a format string), so the BSD-first fallback chain never fell + # over — it fed multi-word filesystem output into the arithmetic below and + # crashed under set -u on Windows Git Bash. `stat -c` fails cleanly on + # BSD/macOS, making GNU-first the deterministic order. Numeric-validate + # before arithmetic as the last line of defense. + CACHE_MTIME=$(stat -c %Y "$CACHE_FILE" 2>/dev/null || stat -f %m "$CACHE_FILE" 2>/dev/null || echo 0) + case "$CACHE_MTIME" in ''|*[!0-9]*) CACHE_MTIME=0 ;; esac + CACHE_AGE=$(( $(date +%s) - CACHE_MTIME )) if [ "$CACHE_AGE" -lt 604800 ]; then # 7 days in seconds MODE=$(grep -o '"mode":"[^"]*"' "$CACHE_FILE" | head -1 | cut -d'"' -f4) [ -n "$MODE" ] && echo "REPO_MODE=$(validate_mode "$MODE")" && exit 0 diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook index d5404c05d7..463b3e4c54 100755 --- a/bin/gstack-settings-hook +++ b/bin/gstack-settings-hook @@ -171,8 +171,9 @@ case "$ACTION" in const matchesEntry = (entry) => { const sameMatcher = (entry.matcher || "") === matcher; + const sameCommand = entry.hooks && entry.hooks[0] && entry.hooks[0].command === cmd; const sameSource = entry._gstack_source === source; - return sameMatcher && sameSource; + return sameMatcher && (sameSource || sameCommand); }; let existing = settings.hooks[event].find(matchesEntry); @@ -184,6 +185,7 @@ case "$ACTION" in if (existing) { existing.hooks = [hookEntry]; + existing._gstack_source = source; } else { const newEntry = { _gstack_source: source, hooks: [hookEntry] }; if (matcher) newEntry.matcher = matcher; diff --git a/bin/gstack-slug b/bin/gstack-slug index e9b2aaf026..9244547223 100755 --- a/bin/gstack-slug +++ b/bin/gstack-slug @@ -27,7 +27,11 @@ # injection when consumed via source or eval. set -euo pipefail -CACHE_DIR="$HOME/.gstack/slug-cache" +# GSTACK_HOME-aware, matching lib/bin-context.ts's native port (#2561): the +# bash writer and the TS reader must key the SAME cache, and a test running +# with GSTACK_HOME= must write its cache junk there, not into the real +# home (observed: 2,528 stale temp-cwd entries accumulated in ~/.gstack). +CACHE_DIR="${GSTACK_HOME:-$HOME/.gstack}/slug-cache" PROJECT_DIR="$(pwd)" # Encode absolute path as cache key: /Users/j/foo → _Users_j_foo CACHE_KEY=$(printf '%s' "$PROJECT_DIR" | tr '/' '_') @@ -37,8 +41,14 @@ SLUG="" # 0. Explicit env override — wins over everything. Escape hatch for vendored # sub-repos and other genuine "subdir IS its own project" edge cases. +SLUG_FROM_ENV=0 if [[ -n "${GSTACK_PROJECT_SLUG:-}" ]]; then SLUG=$(printf '%s' "$GSTACK_PROJECT_SLUG" | tr -cd 'a-zA-Z0-9._-') + # Per-invocation escape hatch, never a durable identity: persisting it + # would rebind THIS cwd's slug for every later env-less run (observed: a + # test exporting GSTACK_PROJECT_SLUG from the repo root rebound the whole + # repo's session state to the test's slug). + SLUG_FROM_ENV=1 fi # 1. Walk up from pwd, tracking the OUTERMOST ancestor with a canonical @@ -160,7 +170,7 @@ SLUG="${SLUG:-$(basename "$PROJECT_DIR" | tr -cd 'a-zA-Z0-9._-')}" # injection, but the invariant should not depend on that reasoning). SLUG=$(printf '%s' "$SLUG" | tr -cd 'a-zA-Z0-9._-') -if [[ -n "$SLUG" ]]; then +if [[ -n "$SLUG" && "$SLUG_FROM_ENV" -eq 0 ]]; then CURRENT_CACHE="" if [[ -f "$CACHE_FILE" ]]; then CURRENT_CACHE=$(cat "$CACHE_FILE" 2>/dev/null || true) diff --git a/bin/gstack-team-init b/bin/gstack-team-init index fd6c1b7d93..99538425f2 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -70,7 +70,11 @@ else **Before doing ANY work, verify gstack is installed:** ```bash -test -d ~/.claude/skills/gstack/bin && echo "GSTACK_OK" || echo "GSTACK_MISSING" +_GS="" +for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.gstack/repos/gstack"; do + [ -z "$_GS" ] && [ -n "$_D" ] && [ -d "$_D/bin" ] && _GS="$_D" +done +[ -n "$_GS" ] && echo "GSTACK_OK: $_GS" || echo "GSTACK_MISSING" ``` If GSTACK_MISSING: STOP. Do not proceed. Tell the user: @@ -87,7 +91,8 @@ Do not skip skills, ignore gstack errors, or work around missing gstack. Using gstack skills: After install, skills like /qa, /ship, /review, /investigate, and /browse are available. Use /browse for all web browsing. -Use ~/.claude/skills/gstack/... for gstack file paths (the global path).' +Use the resolved install path above for gstack file paths +(default: ~/.claude/skills/gstack).' fi # Check if CLAUDE.md already has a gstack section @@ -114,8 +119,17 @@ if [ "$MODE" = "required" ]; then cat > "$HOOKS_DIR/check-gstack.sh" << 'HOOK_EOF' #!/bin/bash # Block skill usage when gstack is not installed globally. - -if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then +# +# Resolve the install root the way gstack skill preambles do: the GSTACK_ROOT +# env var first, then every host's global install location, then the migrated +# repo location. Block only when NONE exist (#2500 — hardcoding +# ~/.claude/skills/gstack false-blocked Codex-host and migrated-repo installs). +_GSTACK_ROOT="" +for _D in "${GSTACK_ROOT:-}" "$HOME/.claude/skills/gstack" "$HOME/.codex/skills/gstack" "$HOME/.factory/skills/gstack" "$HOME/.kiro/skills/gstack" "$HOME/.config/opencode/skills/gstack" "$HOME/.slate/skills/gstack" "$HOME/.cursor/skills/gstack" "$HOME/.openclaw/skills/gstack" "$HOME/.hermes/skills/gstack" "$HOME/.gbrain/skills/gstack" "$HOME/.gstack/repos/gstack"; do + [ -z "$_GSTACK_ROOT" ] && [ -n "$_D" ] && [ -d "$_D/bin" ] && _GSTACK_ROOT="$_D" +done + +if [ -z "$_GSTACK_ROOT" ]; then cat >&2 <<'MSG' BLOCKED: gstack is not installed globally. diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index 17d7d30bcd..9f83386d71 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -12,12 +12,14 @@ # ~/.codex/skills/gstack* — Codex skill install + per-skill symlinks # ~/.factory/skills/gstack* — Factory Droid skill install + per-skill symlinks # ~/.kiro/skills/gstack* — Kiro skill install + per-skill symlinks +# ~/.cursor/skills/gstack* — Cursor skill install + per-skill symlinks # ~/.gstack/ — global state (config, analytics, sessions, projects, # repos, installation-id, browse error logs) # .claude/skills/gstack* — project-local skill install (--local installs) # .gstack/ — per-project browse state (in current git repo) # .gstack-worktrees/ — per-project test worktrees (in current git repo) -# .agents/skills/gstack* — Codex/Gemini/Cursor sidecar (in current git repo) +# .agents/skills/gstack* — Codex/Gemini sidecar (in current git repo) +# .cursor/skills/gstack* — project-local Cursor skills (in current git repo) # Running browse daemons — stopped via SIGTERM before cleanup # # What is NOT REMOVED: @@ -66,6 +68,7 @@ if [ "$FORCE" -eq 0 ]; then [ -d "$HOME/.codex/skills" ] && echo " ~/.codex/skills/gstack*" [ -d "$HOME/.factory/skills" ] && echo " ~/.factory/skills/gstack*" [ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*" + [ -d "$HOME/.cursor/skills" ] && echo " ~/.cursor/skills/gstack*" [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR" if [ -n "$_GIT_ROOT" ]; then @@ -73,6 +76,7 @@ if [ "$FORCE" -eq 0 ]; then [ -d "$_GIT_ROOT/.gstack" ] && echo " $_GIT_ROOT/.gstack/ (browse state + reports)" [ -d "$_GIT_ROOT/.gstack-worktrees" ] && echo " $_GIT_ROOT/.gstack-worktrees/" [ -d "$_GIT_ROOT/.agents/skills" ] && echo " $_GIT_ROOT/.agents/skills/gstack*" + [ -d "$_GIT_ROOT/.cursor/skills" ] && echo " $_GIT_ROOT/.cursor/skills/gstack*" fi # Preview running daemons @@ -130,16 +134,76 @@ fi # ─── Remove global Claude skills ──────────────────────────── CLAUDE_SKILLS="$HOME/.claude/skills" + +# Skill-name inventory (#2563 gate a): every name setup could have installed — +# each source skill's directory name, its frontmatter name, their gstack- +# prefixed variants, and the alias dirs. Built BEFORE the install root is +# removed. A real directory in ~/.claude/skills is only deletable when its +# name is in this inventory AND its SKILL.md carries the generated banner. +# The seed names below are the alias dirs setup's _install_alias_skill_md +# creates (setup: link_claude_root_skill_alias + the connect-chrome call +# sites) — keep in sync with setup if an alias is added or renamed there. +_INVENTORY=" _gstack-command connect-chrome gstack-connect-chrome " +if [ -d "$GSTACK_DIR" ]; then + for _SRC in "$GSTACK_DIR"/*/; do + [ -f "$_SRC/SKILL.md" ] || continue + _SRC_NAME="$(basename "$_SRC")" + _FM_NAME=$(grep -m1 '^name:' "$_SRC/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true) + for _N in "$_SRC_NAME" "$_FM_NAME"; do + [ -n "$_N" ] || continue + case "$_INVENTORY" in *" $_N "*) ;; *) _INVENTORY="$_INVENTORY$_N gstack-$_N " ;; esac + done + done +fi +_in_skill_inventory() { case "$_INVENTORY" in *" $1 "*) return 0 ;; *) return 1 ;; esac; } + +_SKIPPED_DIRS=() if [ -d "$CLAUDE_SKILLS/gstack" ] || [ -L "$CLAUDE_SKILLS/gstack" ]; then - # Remove per-skill symlinks that point into gstack/ - for _LINK in "$CLAUDE_SKILLS"/*; do - [ -L "$_LINK" ] || continue - _NAME="$(basename "$_LINK")" + # Remove per-skill entries created by setup. Three install shapes exist: + # 1. symlink entry (oldest installs) + # 2. real dir + SYMLINKED SKILL.md (standard Unix install) + # 3. real dir + REAL-FILE SKILL.md (Windows copy install, #2563) + # Shape 3 was skipped entirely — gstack-uninstall exited 0 and reported + # success while leaving ~52 gstack-* directories behind on Windows. + for _ENTRY in "$CLAUDE_SKILLS"/*; do + _NAME="$(basename "$_ENTRY")" [ "$_NAME" = "gstack" ] && continue - _TARGET="$(readlink "$_LINK" 2>/dev/null || true)" - case "$_TARGET" in - gstack/*|*/gstack/*) rm -f "$_LINK"; REMOVED+=("claude/$_NAME") ;; - esac + if [ -L "$_ENTRY" ]; then + _TARGET="$(readlink "$_ENTRY" 2>/dev/null || true)" + case "$_TARGET" in + gstack/*|*/gstack/*) rm -f "$_ENTRY"; REMOVED+=("claude/$_NAME") ;; + esac + elif [ -d "$_ENTRY" ] && { [ -f "$_ENTRY/SKILL.md" ] || [ -L "$_ENTRY/SKILL.md" ]; }; then + if [ -L "$_ENTRY/SKILL.md" ]; then + # Shape 2: provenance readable from the symlink target itself. + # Gate 1: the name must be in gstack's skill inventory (parity with + # shape 3). Gate 2: the target must contain "gstack" as an ANCHORED + # path segment (gstack/*|*/gstack/*, same pattern as shape 1) — a + # bare *gstack* substring match would wipe a user's own skill whose + # SKILL.md merely lives under e.g. ~/tools/gstack-fork/. + _TARGET="$(readlink "$_ENTRY/SKILL.md" 2>/dev/null || true)" + if _in_skill_inventory "$_NAME"; then + case "$_TARGET" in + gstack/*|*/gstack/*) rm -rf "$_ENTRY"; REMOVED+=("claude/$_NAME") ;; + *) _SKIPPED_DIRS+=("$_ENTRY") ;; + esac + else + _SKIPPED_DIRS+=("$_ENTRY") + fi + elif _in_skill_inventory "$_NAME" && grep -q ' - -