diff --git a/AGENTS.md b/AGENTS.md index 4df7eec35f..9fd4fa9152 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,8 +64,8 @@ Invoke them by name (e.g., `/office-hours`). | `/benchmark` | Performance regression detection (page load, Core Web Vitals). | | `/benchmark-models` | Cross-model benchmark for skills (Claude, GPT, Gemini side-by-side). | | `/cso` | OWASP Top 10 + STRIDE security audit. | -| `/setup-gbrain` | Set up gbrain for cross-machine session memory sync. | -| `/sync-gbrain` | Keep gbrain current with this repo's code; refresh agent search guidance in CLAUDE.md. | +| `/setup-gbrain` | Configure gbrain, MCP, and per-repository trust without claiming the current repository index is synced. | +| `/sync-gbrain` | Sync code and tracked Markdown through strict version/source/path, expected-state, and preview/apply `plan_digest` gates; a missing source needs a registration invocation followed by a sync invocation, and search guidance requires a trusted live HEAD-bound receipt. | ### Browser + agent integration diff --git a/CHANGELOG.md b/CHANGELOG.md index 351e7cabde..aac6b58f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,114 @@ # Changelog +## [1.63.0.0] - 2026-07-28 + +## **/sync-gbrain now proves which repository commit it indexed before it calls the result current.** + +The repository stage used to make a weak health decision from page count and +path spelling. A source registered as `.` could later look different from the +same worktree's absolute path, trigger remove-and-re-add recovery, and come back +with code indexed but tracked Markdown missing. A successful command could also +leave later stages to imply everything was healthy without a receipt tied to +the live Git commit. + +The new repository-index stage resolves canonical filesystem identity, keeps +the source in place, and runs GBrain `0.42.71.0` or newer with the exact HEAD and +prior bookmark. It validates a dry-run plan, binds apply to the plan's full +immutable `plan_digest`, and requires the apply result to echo that digest. It +indexes admitted code and tracked Markdown with strategy `auto`, then writes a +bounded receipt only after the source, marker, bookmark, strategy, clean HEAD, +plan binding, and result all verify. + +### The numbers that matter + +| Contract | Before | v1.63.0.0 | +|---|---|---| +| Wrapper dry-run probes or writes | Not clearly bounded | 0 | +| Source snapshots around apply | Best-effort | 3 strict reads | +| Affected items stored in the receipt | Unbounded or absent | First 100, plus a digest over all | +| Preview/apply identity | Unbound | Exact shared 64-character `plan_digest` | +| Missing-source bootstrap | Register and continue | Register only, stop, then rerun | +| Required GBrain release | Not pinned to the fix | `0.42.71.0` or newer | + +### What this means for builders + +Run `/sync-gbrain --code-only` when you want only this worktree's repository +index. If its source is missing, the first call registers it and stops; run the +same command again after inspecting the source. `--dry-run` is now explicitly +an `ORCHESTRATION PREVIEW — unvalidated`: it returns before Git, engine, lock, +source, or content probes and makes no writes. Use the validated direct GBrain +preview in `docs/repository-index-recovery.md` when you need to inspect actual +content operations. + +A repository refusal, partial apply, or failed postcondition is terminal. +Memory ingest, cross-machine artifact sync, attach, cleanup, reindex, and dream +do not run past it. A dirty worktree may be indexed deliberately, but it cannot +produce a trusted clean-HEAD receipt. + +### Itemized changes + +#### Added + +- `lib/gbrain-repository-index.ts` implements four-component version parsing, + canonical source identity, exact target/bookmark invocation, strict schema-1 + preview/apply plan binding, bounded receipt evidence, and live receipt + verification. +- `docs/repository-index-recovery.md` documents normal operation, both preview + assurance levels, every result/state class, and paste-ready recovery commands. +- `/sync-gbrain --json` emits one `repository_index` document, while + `--verify-receipt` rebinds persisted evidence to the current canonical root, + attached source, clean full HEAD, and tracked marker without contacting + GBrain. + +#### Changed + +- Every real repository attempt now writes a fail-closed watermark and removes + the prior receipt before the first GBrain probe. A verified result publishes + its new receipt while the watermark still blocks readers, then clears the + watermark. Failed same-HEAD retries therefore cannot resurrect historical + GREEN evidence. +- The read-only receipt verifier refuses both an active wrapper lock and a + superseding attempt watermark before and after its live Git/source checks. + Global skill preambles recommend GBrain repository search only when this + verifier exits zero for the current clean HEAD; otherwise they explicitly + fall back to repository files and `rg`. +- `/setup-gbrain` now delegates read-write repository setup to the safe + repository wrapper. It no longer bypasses the expected-state contract with + raw `gbrain import`/background embedding or writes search guidance from a + generic engine smoke test. +- `bin/gstack-gbrain-sync` is now a real executable launcher (and package bin), + so generated skills, recovery output, and manual invocations share one + runnable entry point. +- `--code-only` now means “repository-index stage only,” covering both code and + tracked Markdown; it no longer implies GBrain strategy `code`. +- The wrapper requires GBrain `0.42.71.0`, forces multimodal admission off in a + copied child environment, and previews strategy `auto` with `--no-pull`, + exact target/bookmark checks, and clean-tree enforcement when applicable. + Apply receives the preview's exact `--expected-plan-digest`; missing, + malformed, changed, or non-echoed plan evidence fails closed. +- Bounded affected-item hashes bind operation, path, slug, and the rename + source path (or an explicit empty field), matching GBrain's schema-1 byte + contract. +- Source-path drift is classified as equivalent, different, or ambiguous. + Different or unprovable paths refuse; the wrapper never repairs them by + deleting and re-adding the source. +- Missing sources use a two-invocation bootstrap. The registration call exits + incomplete with `state_changed: registry_only`; sync and attach wait for the + next invocation. +- Repository terminal failures stop every later stage. Post-apply failures are + reported as `applied_unverified`, never as a rollback or GREEN result. +- `.gbrain-source` and receipts use wrapper-owned atomic replacement; final + source and Git snapshots must still match before cleanup can proceed. + +#### Documentation + +- README, `USING_GBRAIN_WITH_GSTACK.md`, the generated `/sync-gbrain` skill, + error guide, suggestions, and `gstack/llms.txt` now distinguish repository + indexing from cross-machine GStack artifact sync and link the recovery guide. +- Recovery examples validate source/bookmark inputs and quote every shell + argument; generated host skills and public catalogs now describe the + two-invocation bootstrap and live receipt requirement. + ## [1.60.1.0] - 2026-07-09 ## **The /autoplan dual-voice eval is back on the board, catching real regressions.** diff --git a/CLAUDE.md b/CLAUDE.md index 9848449020..30235bc091 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -982,9 +982,13 @@ enhancement layered on top, never a dependency.) ## GBrain Search Guidance (configured by /sync-gbrain) -GBrain is set up and synced on this machine. The agent should prefer gbrain -over Grep when the question is semantic or when you don't know the exact -identifier yet. +This checked-in block is guidance, not proof of live index freshness. Before +relying on GBrain for this repository, the skill preamble must run +`./bin/gstack-gbrain-sync --verify-receipt --json --quiet` and observe all +three exact markers: `"status":"verified"`, +`"state_changed":"applied_verified"`, and `"trusted":true`. Only then should +the agent prefer GBrain when the question is semantic or the exact identifier +is unknown. **This worktree is pinned to a worktree-scoped code source** via the `.gbrain-source` file in the repo root (kubectl-style context). Any @@ -992,7 +996,8 @@ identifier yet. call from anywhere under this worktree routes to that source by default — no `--source` flag needed. Conductor sibling worktrees of the same repo each have their own pin and their own indexed pages, so semantic results -match the actual code on disk in this worktree. +are isolated by worktree. The pin alone never proves that indexed content +matches the current HEAD. Two indexed corpora available via the `gbrain` CLI: - This worktree's code (auto-pinned via `.gbrain-source`). @@ -1009,15 +1014,10 @@ Prefer gbrain when: - "What did we decide last time?" / past plans, retros, learnings: `gbrain search "" --source gstack-brain-` -Grep is still right for known exact strings, regex, multiline patterns, and -file globs. Run `/sync-gbrain` after meaningful code changes; for ongoing -auto-sync across all worktrees, run `gbrain autopilot --install` once per -machine — gbrain's daemon handles incremental refresh on a schedule. - -Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the -orchestrator refuses destructive source ops when it detects a running autopilot -to avoid racing it (#1734). Prefer registering user repos with `gbrain sources -add --path ` (no `--url`): URL-managed sources can auto-reclone, and the -sync code walk for them requires an explicit `--allow-reclone` opt-in. +If receipt verification fails, is unavailable, or does not return all three +exact markers, use repository files and `rg` instead. Run +`/sync-gbrain --code-only`; source registration may require one bootstrap +invocation and a second invocation for the expected-state sync. Re-verify +after every committed source-code or tracked-Markdown change. diff --git a/README.md b/README.md index af534d2c53..1f20007b65 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan- | `/open-gstack-browser` | **GStack Browser** — launch GStack Browser with sidebar, anti-bot stealth, auto model routing (Sonnet for actions, Opus for analysis), one-click cookie import, and Claude Code integration. Clean up pages, take smart screenshots, edit CSS, and pass info back to your terminal. | | `/setup-deploy` | **Deploy Configurator** — one-time setup for `/land-and-deploy`. Detects your platform, production URL, and deploy commands. | | `/setup-gbrain` | **GBrain Onboarding** — from zero to running gbrain in under 5 minutes. PGLite local, Supabase existing URL, or auto-provision a new Supabase project via Management API. MCP registration for Claude Code + per-repo trust triad (read-write/read-only/deny). [Full guide](USING_GBRAIN_WITH_GSTACK.md). | -| `/sync-gbrain` | **Keep Brain Current** — re-index this repo's code into gbrain via `gbrain sources add` + `gbrain sync --strategy code`, refresh the `## GBrain Search Guidance` block in CLAUDE.md, and auto-remove guidance when the capability check fails. `--incremental` (default), `--full`, `--dry-run`. Idempotent; safe to re-run. | +| `/sync-gbrain` | **Keep Repository Index Current** — sync this worktree's code and tracked Markdown through a strict GBrain version/source/path gate, bind the exact HEAD/bookmark, and write a bounded verification receipt. `--code-only` means repository-index stage only. `--dry-run` is an explicitly unvalidated no-probe orchestration preview. [Sync and recovery](docs/repository-index-recovery.md). | | `/gstack-upgrade` | **Self-Updater** — upgrade gstack to latest. Detects global vs vendored install, syncs both, shows what changed. | | `/ios-qa` | **iOS Live-Device QA (v1.43.0.0+)** — drive a real iPhone over USB CoreDevice via an embedded `StateServer` in the app. Read Swift source, codegen typed `@Observable` accessors, run the agent loop. Optional `--tailnet` flag exposes the device to OpenClaw or any HTTP-capable agent on your Tailscale tailnet so remote agents can run iOS QA without ever touching the hardware. Capability-tier allowlist (observe/interact/mutate/restore), per-device session lock, audit log. | | `/ios-fix`, `/ios-design-review`, `/ios-clean`, `/ios-sync` | iOS bug-fix loop, designer's-eye HIG audit, debug-bridge cleanup, and accessor resync. See `docs/skills.md`. End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). | @@ -405,7 +405,9 @@ Four paths, pick one: After init, the skill offers to register gbrain as an MCP server for Claude Code (`claude mcp add gbrain -- gbrain serve`) so `gbrain search`, `gbrain put`, etc. show up as first-class typed tools — not bash shell-outs. -**Keeping the brain current.** Run `/sync-gbrain` from any repo to re-index its code into gbrain (incremental by default, `--full` for a full reindex, `--dry-run` to preview). The skill registers the cwd as a federated source via `gbrain sources add`, runs `gbrain sync --strategy code`, and writes a `## GBrain Search Guidance` block to your project's CLAUDE.md so the agent prefers `gbrain search`/`code-def`/`code-refs` over Grep. The block is removed automatically if the capability check fails — no stale guidance pointing at tools that aren't installed. +**Keeping the repository index current.** Run `/sync-gbrain` after committed code or tracked Markdown changes. Compatible releases use `gbrain sync --strategy auto --no-pull` with the exact source, canonical worktree root, full HEAD, and prior bookmark. A missing source is registered on the first invocation and deliberately stops before content sync; the second invocation can plan against its real bookmark. For an existing source, the wrapper validates a schema-1 dry-run, passes its full immutable `plan_digest` through `--expected-plan-digest`, and requires apply to echo the same digest. Before its first GBrain probe, every real attempt invalidates the prior receipt with a fail-closed watermark. After clean-HEAD bookmark, strategy, wrapper-owned source-marker attachment, a final strict source reread, bounded affected-item, zero-image-operation, and paired plan checks pass, a fully verified run writes `~/.gstack/.gbrain-repository-index-receipt.json` while the watermark still blocks readers, then clears the watermark to reveal the receipt. Consume it through `/sync-gbrain --verify-receipt --json`, which refuses active lock/watermark state and binds the persisted evidence to the live canonical root and full HEAD. + +`/sync-gbrain --dry-run` is only **`ORCHESTRATION PREVIEW — unvalidated`**: it performs no Git, engine, source, path, or content probes. Use the version/source-gated direct GBrain preview in the [repository index sync and recovery guide](docs/repository-index-recovery.md) when you need an actual content plan. GBrain `0.42.71.0` is the paired-release floor. **Per-remote trust policy.** Each repo on your machine gets one of three tiers: @@ -425,7 +427,7 @@ gstack-brain-init **Full monty — every scenario, every flag, every bin helper, every troubleshooting step:** [USING_GBRAIN_WITH_GSTACK.md](USING_GBRAIN_WITH_GSTACK.md) -Other references: [docs/gbrain-sync.md](docs/gbrain-sync.md) (sync-specific guide) • [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md) (error index) +Other references: [repository index sync and recovery](docs/repository-index-recovery.md) • [cross-machine GStack artifact sync](docs/gbrain-sync.md) • [artifact-sync error index](docs/gbrain-sync-errors.md) ## Docs @@ -435,7 +437,8 @@ Other references: [docs/gbrain-sync.md](docs/gbrain-sync.md) (sync-specific guid | [Diagrams & Document Formats](docs/howto-diagrams-and-formats.md) | Mermaid/excalidraw fences in PDFs, image sizing and safety defaults, `--to html\|docx`, `/diagram` triplets | | [Builder Ethos](ETHOS.md) | Builder philosophy: Boil the Ocean, Search Before Building, three layers of knowledge | | [Using GBrain with GStack](USING_GBRAIN_WITH_GSTACK.md) | Every path, flag, bin helper, and troubleshooting step for `/setup-gbrain` | -| [GBrain Sync](docs/gbrain-sync.md) | Cross-machine memory setup, privacy modes, troubleshooting | +| [Repository Index Sync and Recovery](docs/repository-index-recovery.md) | Version/source/path gates, preview assurance, receipts, and recovery | +| [GStack Artifact Sync](docs/gbrain-sync.md) | Cross-machine memory setup, privacy modes, troubleshooting | | [Architecture](ARCHITECTURE.md) | Design decisions and system internals | | [Browser Reference](BROWSER.md) | Full command reference for `/browse` | | [Contributing](CONTRIBUTING.md) | Dev setup, testing, contributor mode, and dev mode | diff --git a/SKILL.md b/SKILL.md index aaa5612dd4..863daea9a4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -339,33 +339,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/USING_GBRAIN_WITH_GSTACK.md b/USING_GBRAIN_WITH_GSTACK.md index ec1144c9a5..71468144e4 100644 --- a/USING_GBRAIN_WITH_GSTACK.md +++ b/USING_GBRAIN_WITH_GSTACK.md @@ -4,7 +4,7 @@ Your coding agent, with a memory it actually keeps. [GBrain](https://github.com/garrytan/gbrain) is a persistent knowledge base designed for AI agents. It stores what your agent learns, what you've decided, what worked and what didn't, and lets the agent search all of it on demand. GStack gives you a one-command path from zero to "gbrain is running, and my agent can call it" — with paths for try-it-local, share-with-your-team, and everything between. -This is the full monty: every scenario, every flag, every helper bin, every troubleshooting step. For the quick pitch, see the [README's GBrain section](README.md#gbrain--persistent-knowledge-for-your-coding-agent). For error codes and sync-specific issues, see [docs/gbrain-sync.md](docs/gbrain-sync.md). +This is the full monty: every scenario, every flag, every helper bin, every troubleshooting step. For the quick pitch, see the [README's GBrain section](README.md#gbrain--persistent-knowledge-for-your-coding-agent). Repository-index refusal and recovery live in [docs/repository-index-recovery.md](docs/repository-index-recovery.md); cross-machine GStack artifact transport and its errors live in [docs/gbrain-sync.md](docs/gbrain-sync.md) and [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md). --- @@ -20,7 +20,7 @@ That's it. The skill detects your current state, asks three questions at most, a Once `/setup-gbrain` finishes, your coding agent has two retrieval surfaces it didn't have before: -- **Semantic code search across this repo.** `gbrain search "browser security canary"` returns ranked file regions, not exact-match grep hits. `gbrain code-def`, `code-refs`, `code-callers`, `code-callees` walk the call graph by symbol — useful when you don't know which file holds the implementation but you know what it does. The agent prefers these over Grep when the question is semantic; CLAUDE.md gets a `## GBrain Search Guidance` block that teaches it the routing rules. +- **Semantic repository search across this repo.** `gbrain search "browser security canary"` returns ranked code or tracked-Markdown regions, not exact-match grep hits. `gbrain code-def`, `code-refs`, `code-callers`, `code-callees` walk the call graph by symbol — useful when you don't know which file holds the implementation but you know what it does. The agent prefers these over Grep when the question is semantic; CLAUDE.md gets a `## GBrain Search Guidance` block that teaches it the routing rules. - **Cross-session memory.** Plans, retros, decisions, and learnings from past sessions live in `~/.gstack/` and (if you opted in to artifacts sync) get pushed to a private git repo that gbrain indexes. `gbrain search "what did we decide about auth?"` actually finds the prior CEO plan instead of you re-describing context every session. If you also enabled remote MCP (Path 4 below), brain queries route to a shared brain server that other machines can write to — your laptop, your desktop, and a teammate's machine all see the same memory. @@ -119,34 +119,63 @@ SSH and HTTPS remote variants collapse to the same key: `https://github.com/foo/ Storage: `~/.gstack/gbrain-repo-policy.json`, mode 0600, schema-versioned so future migrations stay deterministic. -## Keeping the brain current with `/sync-gbrain` +## Keeping the repository index current with `/sync-gbrain` -`/setup-gbrain` is one-time onboarding. `/sync-gbrain` is the verb you run every time you want gbrain to see fresh changes in this repo's code. +`/setup-gbrain` is one-time onboarding. Run `/sync-gbrain` after committed +source-code or tracked Markdown changes that should be searchable. ```bash -/sync-gbrain # incremental: mtime fast-path, ~seconds on a clean tree -/sync-gbrain --full # full reindex (~25-35 minutes on a big Mac) -/sync-gbrain --code-only # only the code stage; skip memory + brain-sync -/sync-gbrain --dry-run # preview what would sync; no writes +/sync-gbrain # repository index, then enabled memory/artifact stages +/sync-gbrain --code-only # repository-index stage only: code + tracked Markdown +/sync-gbrain --dry-run # ORCHESTRATION PREVIEW — unvalidated; no probes or writes ``` -The skill runs three stages — code, memory, brain-sync — independently. A failure in one doesn't block the others. State persists to `~/.gstack/.gbrain-sync-state.json` so re-running picks up cleanly. - -**What it does on a fresh worktree:** - -1. **Pre-flight.** Checks `gbrain_local_status` (the local engine's health). If the engine is `broken-db` or `broken-config`, the skill STOPs with a remediation menu — it refuses to silently degrade. If the local engine is missing and you're in remote-MCP mode (Path 4), the code stage SKIPs cleanly and only brain-sync runs. -2. **Code stage.** Registers the cwd as a federated source via `gbrain sources add`, writes a `.gbrain-source` pin file in the repo root (kubectl-style context — every worktree gets its own pin, so Conductor sibling worktrees don't collide), runs `gbrain sync --strategy code`. -3. **Memory stage.** Stages your `~/.gstack/` transcripts + curated memory. In local-stdio MCP mode, ingests into the local engine. In remote-http MCP mode, persists staged markdown to `~/.gstack/transcripts/run--/` for the remote brain admin's pull pipeline. The ingest timeout is 30 minutes by default; raise it for a big brain with `GSTACK_INGEST_TIMEOUT_MS` (accepts 1 min–24h). On timeout the gbrain import checkpoint is preserved, so the next `/sync-gbrain` resumes instead of starting over. -4. **Brain-sync stage.** Pushes curated artifacts (plans, designs, retros) to your private artifacts repo if you have one configured. -5. **CLAUDE.md guidance.** Capability-checks the round-trip (write a page → search → find it). If green, writes the `## GBrain Search Guidance` block to your project's CLAUDE.md. If red, REMOVES the block — the agent should never be told to use a tool that isn't installed. - -**The watermark.** Sync state advances by commit hash. If gbrain hits a file it can't index (5 MB hard limit per file, or a file vanished mid-sync), the watermark stays put and subsequent syncs retry. To acknowledge an unfixable failure and move past it: - -```bash -gbrain sync --source --skip-failed -``` - -Re-runnable, idempotent, safe to run from multiple terminals on the same machine (locked at `~/.gstack/.sync-gbrain.lock`). +Repository indexing requires GBrain `0.42.71.0` or newer. A real run proves one +unique source snapshot, canonical path identity, full Git HEAD, and prior +bookmark before invoking `gbrain sync --strategy auto --no-pull` with exact +expected-state arguments. It accepts only one schema-1 child document and +writes `~/.gstack/.gbrain-repository-index-receipt.json` after post-sync +bookmark/strategy, wrapper-owned source-marker replacement, a final strict +source reread, clean-HEAD, bounded affected-item, and zero-image checks pass. +Before use, `gstack-gbrain-sync --verify-receipt --json` binds that persisted +evidence to the live canonical root, attached source, and full clean HEAD. + +`--dry-run` is deliberately not a content preview. It exits before Git, +engine, source, path, or content inspection and reports +`blocked_until_version_proven` / `state_changed: none`. For a real validated +content plan, first prove version/source/root/HEAD/bookmark, then use the exact +source-scoped command in +[Repository index sync and recovery](docs/repository-index-recovery.md). + +**Fresh worktree bootstrap is two invocations:** + +1. The first real run sees the source is absent, registers only the canonical + path, returns `source_registered` / `registry_only`, and exits 2. +2. The second run can bind `--expected-bookmark ` and apply content. + +The wrapper never updates a mismatched registration by remove/re-add. A +different or ambiguous canonical path is a terminal refusal requiring owner +review. + +The wrapper's stages are: + +1. **Repository index.** Code + tracked Markdown for this worktree, pinned by + `.gbrain-source`. A refusal, partial apply, or unverified apply stops later + stages. +2. **Memory ingest.** Local `~/.gstack/` transcripts and curated memory, or a + staged handoff in remote-http mode. +3. **GStack artifact sync.** Curated `~/.gstack/` files pushed to a private Git + repository. This is transport, not the repository index. +4. **Agent guidance.** Capability-checks GBrain and maintains the + `## GBrain Search Guidance` block without treating page count as proof. + +Only `synced`, `first_sync`, or `up_to_date` can reach repository verification. +`partial`, `blocked_by_failures`, malformed JSON, path drift, or a failed +postcondition never becomes GREEN. Do not automatically use `--skip-failed`, +break locks, or alter source registration; follow the recovery guide. + +The wrapper lock is `~/.gstack/.sync-gbrain.lock`. GBrain separately owns its +source lifecycle lock; neither should be broken automatically. ## Switching engines later @@ -160,9 +189,9 @@ The skill runs `gbrain migrate --to supabase --url "$URL"` wrapped in `timeout 1 **If migration hangs:** another gstack session may be holding a lock on the source brain. The timeout fires at 3 minutes with an actionable message. Close other workspaces and re-run. -## GStack memory sync (a separate concern) +## GStack artifact sync (a separate concern) -This is different from gbrain itself. Your gstack state (`~/.gstack/` — learnings, plans, retros, timeline, developer profile) is machine-local by default. "GStack memory sync" optionally pushes a curated, secret-scanned subset to a private git repo so your memory follows you across machines — and, if you're running gbrain, that git repo becomes indexable there too. +This is different from repository indexing. Your gstack state (`~/.gstack/` — learnings, plans, retros, timeline, developer profile) is machine-local by default. GStack artifact sync optionally pushes a curated, secret-scanned subset to a private Git repository so your memory follows you across machines. The Git push is transport only; GBrain indexes that artifact repository only when its separate source wireup is configured and synced. Turn it on with: @@ -176,7 +205,7 @@ Secret-shaped content (AWS keys, GitHub tokens, PEM blocks, JWTs, bearer tokens) **On a new machine:** Copy `~/.gstack-brain-remote.txt` over, run `gstack-brain-restore`, and yesterday's learnings surface on today's laptop. -Full guide: [docs/gbrain-sync.md](docs/gbrain-sync.md). Error index: [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md). +Artifact-sync guide: [docs/gbrain-sync.md](docs/gbrain-sync.md). Artifact-sync error index: [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md). `/setup-gbrain` offers to wire this up for you at the end of initial setup — it's one more AskUserQuestion, and it integrates with the same private-repo infrastructure. @@ -340,29 +369,29 @@ You edited `~/.gstack/gbrain-repo-policy.json` by hand with legacy `allow` value `/health` treats that as yellow, not red. Check `gbrain doctor --json | jq .checks` to see which sub-checks are warning. Typical causes: resolver MECE overlap (skill names clashing) or DB connection not yet configured. -### `/sync-gbrain` reports `OK` but `gbrain search` returns nothing semantic +### Repository receipt is GREEN but `gbrain search` returns nothing semantic -Embeddings probably failed during import. Symbol queries (`code-def`, `code-refs`) still work because they don't need embeddings, but `gbrain search ""` falls back to a degraded BM25 path. Look in the sync output for lines like: +Repository GREEN proves content sync at an exact clean HEAD; it does not +automatically prove embeddings, extraction, or semantic search readiness. +Inspect `evidence.corpus.embedding_status`, `extraction_status`, and +`search_ready` in +`~/.gstack/.gbrain-repository-index-receipt.json`. If embedding failed, +`gbrain search ""` may fall back to a degraded lexical path. Look in the +sync output for lines like: ``` [gbrain] embedding failed for code file : OpenAI embedding requires OPENAI_API_KEY ``` -The fix is to put a provider API key in the process env before re-running. `VOYAGE_API_KEY` is preferred for code (gstack defaults PGLite to `voyage-code-3` when set); otherwise `OPENAI_API_KEY` falls back to `text-embedding-3-large`. On a bare Mac shell, source the key from `~/.zshrc` before calling. In Conductor, the `lib/conductor-env-shim.ts` shim promotes `GSTACK_ANTHROPIC_API_KEY` / `GSTACK_OPENAI_API_KEY` to their canonical names automatically; for `VOYAGE_API_KEY`, set it directly in your Conductor workspace env. Re-run `/sync-gbrain --code-only` to backfill embeddings on already-imported pages. +The fix is to put a provider API key in the process env before re-running. `VOYAGE_API_KEY` is preferred for code (gstack defaults PGLite to `voyage-code-3` when set); otherwise `OPENAI_API_KEY` falls back to `text-embedding-3-large`. On a bare Mac shell, source the key from `~/.zshrc` before calling. In Conductor, the `lib/conductor-env-shim.ts` shim promotes `GSTACK_ANTHROPIC_API_KEY` / `GSTACK_OPENAI_API_KEY` to their canonical names automatically; for `VOYAGE_API_KEY`, set it directly in your Conductor workspace env. Re-run `/sync-gbrain --code-only`, then require a new receipt whose semantic fields prove readiness. ### `gbrain sync` blocked at a commit hash — `FILE_TOO_LARGE` -A file in your tree exceeds gbrain's 5 MB hard limit (`MAX_FILE_SIZE` in `gbrain/src/core/import-file.ts`). Common culprits: response replay caches, captured screenshots, large JSON fixtures. Gbrain doesn't honor `.gitignore`-style exclude lists for code sync; the only knob is acknowledging the failure: - -```bash -gbrain sync --source --skip-failed -``` - -Watermark advances past the offending commit. The same file fails again if it changes; re-skip when that happens. +A file in your tree exceeds gbrain's 5 MB hard limit (`MAX_FILE_SIZE` in `gbrain/src/core/import-file.ts`). Common culprits: response replay caches, captured screenshots, or large JSON fixtures. Fix, remove, or deliberately relocate the file and rerun the same expected-state sync. Do not automatically use `--skip-failed`: it changes bookmark semantics and cannot produce the wrapper's trusted receipt without an explicitly reviewed recovery. ### Switching PGLite → Supabase hangs -Another gstack session in a sibling Conductor workspace may be holding a lock on your local PGLite file via its preamble's `gstack-brain-sync` call. Close other workspaces, re-run `/setup-gbrain --switch`. The timeout is bounded at 180s so you'll never actually wait forever. +Another GBrain process—commonly `gbrain serve`, repository sync, or local memory ingest—may be holding the PGLite file. `gstack-brain-sync` itself is Git-only artifact transport and is not the PGLite writer. Close the actual GBrain holder, then re-run `/setup-gbrain --switch`. The timeout is bounded at 180s. ## Why this design diff --git a/VERSION b/VERSION index c4190e0048..232d60188a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.60.1.0 +1.63.0.0 diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index 5346f1d437..6102a1f976 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -474,33 +474,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/benchmark-models/SKILL.md b/benchmark-models/SKILL.md index 6a9d62616d..54f1920881 100644 --- a/benchmark-models/SKILL.md +++ b/benchmark-models/SKILL.md @@ -343,33 +343,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/benchmark/SKILL.md b/benchmark/SKILL.md index 9451d2d4f9..23c087a612 100644 --- a/benchmark/SKILL.md +++ b/benchmark/SKILL.md @@ -343,33 +343,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/bin/gstack-gbrain-sync b/bin/gstack-gbrain-sync new file mode 100755 index 0000000000..c9f87693e3 --- /dev/null +++ b/bin/gstack-gbrain-sync @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec bun "$SCRIPT_DIR/gstack-gbrain-sync.ts" "$@" diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 07a94af3f4..7cf3b9b583 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -4,22 +4,21 @@ * * Orchestrates three storage tiers per plan §"Storage tiering": * - * 1. Code (current repo) → `gbrain sources add` (idempotent via - * lib/gbrain-sources.ts) + `gbrain sync - * --strategy code` (incremental) or - * `gbrain reindex-code --yes` (--full). - * NEVER `gbrain import` (markdown only). + * 1. Repository index → expected-state `gbrain sync + * --strategy auto --no-pull` for supported + * code and tracked Markdown. * 2. Transcripts + curated memory → gstack-memory-ingest (typed put_page) * 3. Curated artifacts to git → gstack-brain-sync (existing pipeline) * * Modes: * --incremental (default) — mtime fast-path; runs all 3 stages with cache hits - * --full — first-run; full walk + reindex; honest budget per ED2 + * --full — bulk memory ingest + eligible dream cycle; the + * repository stage keeps the same safe auto contract * --dry-run — preview what would sync; no writes anywhere (incl. state file) * * Concurrency safety per /plan-eng-review D1: * - Lock file at ~/.gstack/.sync-gbrain.lock (PID + start ts). - * - Stale-lock takeover after 5 min (process death). + * - Existing locks fail closed; the wrapper never auto-breaks them. * - State file written via tmp+rename for atomicity. * - Lock released in finally; SIGINT/SIGTERM trapped for cleanup. * @@ -29,19 +28,53 @@ * than building a gstack-side daemon. */ -import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync } from "fs"; +import { + existsSync, + statSync, + mkdirSync, + writeFileSync, + readFileSync, + unlinkSync, + renameSync, +} from "fs"; import { join, dirname } from "path"; import { execSync, spawnSync } from "child_process"; import { homedir, hostname } from "os"; import { createHash } from "crypto"; import "../lib/conductor-env-shim"; -import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers"; -import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources"; -import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards"; -import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status"; -import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec"; +import { + detectEngineTier, + withErrorContext, + canonicalizeRemote, +} from "../lib/gstack-memory-helpers"; +import { + parseSourcesList, + cycleCompleted, + type CycleStatus, +} from "../lib/gbrain-sources"; +import { detectAutopilot, decideSourceRemove } from "../lib/gbrain-guards"; +import { + localEngineStatus, + type LocalEngineStatus, +} from "../lib/gbrain-local-status"; +import { + buildGbrainEnv, + spawnGbrain, + execGbrainJson, + NEEDS_SHELL_ON_WINDOWS, +} from "../lib/gbrain-exec"; import { checkOwnedStagingDir } from "../lib/staging-guard"; +import { + orchestrationPreviewResult, + renderRepositoryIndexResult, + runRepositoryIndex, + verifyCurrentRepositoryIndexReceipt, + REPOSITORY_INDEX_RECOVERY_DOC, + REPOSITORY_INDEX_LOCK, + type RepositoryIndexResult, + type RepositoryIndexRunOutput, +} from "../lib/gbrain-repository-index"; // ── Types ────────────────────────────────────────────────────────────────── @@ -60,6 +93,17 @@ export interface CliArgs { noDream: boolean; /** #1734: opt-in to sync a URL-managed source whose code walk may auto-reclone. */ allowReclone: boolean; + /** Emit the schema-1 repository-index result as exactly one stdout document. */ + json: boolean; + /** Rebind the persisted receipt to the live canonical root and clean HEAD. */ + verifyReceipt: boolean; +} + +class CliArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = "CliArgumentError"; + } } interface CodeStageDetail { @@ -67,7 +111,8 @@ interface CodeStageDetail { source_path?: string; page_count?: number | null; last_imported?: string; - status?: "ok" | "skipped" | "failed" | "refused-autopilot" | "refused-reclone"; + status?: + "ok" | "skipped" | "failed" | "refused-autopilot" | "refused-reclone"; } interface StageResult { @@ -85,6 +130,10 @@ interface StageResult { warn?: boolean; /** Stage-specific structured detail. Code stage carries source_id + page_count. */ detail?: CodeStageDetail; + /** Stable process mapping for stages whose contract distinguishes retry=2. */ + exit_code?: 0 | 1 | 2; + /** Machine-readable repository-index envelope, when this is the code stage. */ + repository_result?: RepositoryIndexResult; } // ── Constants ────────────────────────────────────────────────────────────── @@ -92,8 +141,7 @@ interface StageResult { const HOME = homedir(); const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack"); const STATE_PATH = join(GSTACK_HOME, ".gbrain-sync-state.json"); -const LOCK_PATH = join(GSTACK_HOME, ".sync-gbrain.lock"); -const STALE_LOCK_MS = 5 * 60 * 1000; +const LOCK_PATH = join(GSTACK_HOME, REPOSITORY_INDEX_LOCK); // Dream (call-graph build) is brain-global and runs LOCK-FREE after the sync // lock releases, so it can't use the sync lock to dedupe across worktrees. A @@ -110,7 +158,10 @@ const DREAM_MARKER_STALE_MS = DEFAULT_DREAM_TIMEOUT_MS; * module-load-time const captures the real ~/.gstack before a test can redirect. */ export function dreamMarkerPath(): string { - return join(process.env.GSTACK_HOME || join(homedir(), ".gstack"), ".dream-in-progress"); + return join( + process.env.GSTACK_HOME || join(homedir(), ".gstack"), + ".dream-in-progress", + ); } // Default 35-minute timeout for code-walk + memory-ingest stages. Override via @@ -118,8 +169,8 @@ export function dreamMarkerPath(): string { // in resolveStageTimeoutMs below so wildly-low values don't make resume // useless and wildly-high values don't mask config typos. See #1611. const DEFAULT_STAGE_TIMEOUT_MS = 35 * 60 * 1000; // 2_100_000ms = 35min -const MIN_STAGE_TIMEOUT_MS = 60_000; // 1 minute floor -const MAX_STAGE_TIMEOUT_MS = 86_400_000; // 24 hour ceiling +const MIN_STAGE_TIMEOUT_MS = 60_000; // 1 minute floor +const MAX_STAGE_TIMEOUT_MS = 86_400_000; // 24 hour ceiling /** * Parse a stage-timeout env value with bounds validation. Returns the bounded @@ -190,7 +241,12 @@ export function readGbrainCheckpoint(): GbrainCheckpoint | null { export type ResumeVerdict = | { kind: "no-checkpoint" } - | { kind: "resume"; stagingDir: string; processedIndex: number; totalFiles: number } + | { + kind: "resume"; + stagingDir: string; + processedIndex: number; + totalFiles: number; + } | { kind: "stale-staging-missing"; stagingDir: string; reason?: string }; /** @@ -213,7 +269,11 @@ export function decideResume(gstackHome: string = GSTACK_HOME): ResumeVerdict { // so we don't double-log the same event from here and the call site. const verdict = checkOwnedStagingDir(stagingDir, gstackHome); if (!verdict.ok) { - return { kind: "stale-staging-missing", stagingDir, reason: verdict.reason }; + return { + kind: "stale-staging-missing", + stagingDir, + reason: verdict.reason, + }; } return { kind: "resume", @@ -230,34 +290,41 @@ function printUsage(): void { Modes: --incremental Default. mtime fast-path; ~50ms steady-state. - --full First-run; full walk + reindex. Honest ~25-35 min for big Macs (ED2). - --dry-run Preview what would sync; no writes anywhere. + --full Bulk memory ingest and eligible dream cycle. Repository + indexing still uses expected-state strategy=auto. + --dry-run ORCHESTRATION PREVIEW only: no engine, source, path, + Git, or content probes; output is explicitly unvalidated. Options: --quiet Suppress per-stage output. --no-code Skip the cwd code-import stage. --no-memory Skip the gstack-memory-ingest stage (transcripts + artifacts). --no-brain-sync Skip the gstack-brain-sync git pipeline stage. - --code-only Only run the code-import stage (alias for --no-memory --no-brain-sync). + --code-only Legacy alias: only run the repository-index stage + (supported code + tracked Markdown); alias for + --no-memory --no-brain-sync. --dream Force the source-scoped dream cycle that builds this source's call graph (gbrain code-callers/code-callees). Runs lock-free AFTER the sync stages. ~minutes. Default timeout 45min, override GSTACK_SYNC_DREAM_TIMEOUT_MS. --no-dream Opt out of the dream cycle that --full would auto-run. - --allow-reclone Permit the code walk for URL-managed sources (remote_url set) - even though gbrain may auto-reclone the working tree (#1734). + --allow-reclone Legacy compatibility flag. The expected-state repository + path always uses --no-pull and never auto-reclones. + --json Emit one schema-1 repository-index JSON document. + --verify-receipt Read-only: verify the persisted GREEN receipt belongs + to this live canonical worktree and clean full HEAD. --help This text. -Stages run in order: code → memory ingest → curated git push, then (lock-free) -the optional dream call-graph build. --full auto-runs dream ONLY when the call -graph was never built; --dream always forces it. Each stage failure is -non-fatal; subsequent stages still run. +Stages run in order: repository index → memory ingest → curated git push, then +the optional lock-free dream call-graph build. A repository-index refusal, +partial apply, or unverified apply is terminal; unrelated later stages do not +run. --full auto-runs dream only when the call graph was never built. `); } -function parseArgs(): CliArgs { - const args = process.argv.slice(2); +function parseArgs(args: string[] = process.argv.slice(2)): CliArgs { let mode: Mode = "incremental"; + const explicitModes = new Set(); let quiet = false; let noCode = false; let noMemory = false; @@ -266,18 +333,46 @@ function parseArgs(): CliArgs { let dream = false; let noDream = false; let allowReclone = false; + let json = false; + let verifyReceipt = false; + const jsonRequested = args.includes("--json"); for (let i = 0; i < args.length; i++) { const a = args[i]; switch (a) { - case "--incremental": mode = "incremental"; break; - case "--full": mode = "full"; break; - case "--dry-run": mode = "dry-run"; break; - case "--quiet": quiet = true; break; - case "--no-code": noCode = true; break; - case "--no-memory": noMemory = true; break; - case "--no-brain-sync": noBrainSync = true; break; - case "--allow-reclone": allowReclone = true; break; + case "--incremental": + explicitModes.add("incremental"); + mode = "incremental"; + break; + case "--full": + explicitModes.add("full"); + mode = "full"; + break; + case "--dry-run": + explicitModes.add("dry-run"); + mode = "dry-run"; + break; + case "--quiet": + quiet = true; + break; + case "--no-code": + noCode = true; + break; + case "--no-memory": + noMemory = true; + break; + case "--no-brain-sync": + noBrainSync = true; + break; + case "--allow-reclone": + allowReclone = true; + break; + case "--json": + json = true; + break; + case "--verify-receipt": + verifyReceipt = true; + break; case "--code-only": codeOnly = true; noMemory = true; @@ -285,40 +380,161 @@ function parseArgs(): CliArgs { break; // --dream forces the cycle; --full only chains it at the call site (so // --no-dream can override) — do NOT set dream from --full here. - case "--dream": dream = true; break; - case "--no-dream": noDream = true; break; + case "--dream": + dream = true; + break; + case "--no-dream": + noDream = true; + break; case "--help": case "-h": + if (jsonRequested) { + throw new CliArgumentError("--help cannot be combined with --json"); + } printUsage(); process.exit(0); default: - console.error(`Unknown argument: ${a}`); - printUsage(); - process.exit(1); + throw new CliArgumentError(`Unknown argument: ${a}`); } } + if (explicitModes.size > 1) { + throw new CliArgumentError( + `Conflicting modes: ${[...explicitModes] + .map((value) => `--${value}`) + .join(", ")}`, + ); + } + if ( + verifyReceipt && + (explicitModes.size > 0 || + noCode || + noMemory || + noBrainSync || + codeOnly || + dream || + noDream || + allowReclone) + ) { + throw new CliArgumentError( + "--verify-receipt can only be combined with --json or --quiet", + ); + } - return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly, dream, noDream, allowReclone }; + return { + mode, + quiet, + noCode, + noMemory, + noBrainSync, + codeOnly, + dream, + noDream, + allowReclone, + json, + verifyReceipt, + }; } // ── Helpers ──────────────────────────────────────────────────────────────── function repoRoot(): string | null { try { - const out = execSync("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 2000 }); + const out = execSync("git rev-parse --show-toplevel", { + encoding: "utf-8", + timeout: 2000, + }); return out.trim(); } catch { return null; } } -function originUrl(): string | null { - try { - const out = execSync("git remote get-url origin", { encoding: "utf-8", timeout: 2000 }); - return out.trim(); - } catch { - return null; +function gitText(args: string[], cwd: string): string | null { + const result = spawnSync("git", args, { + cwd, + encoding: "utf-8", + timeout: 5_000, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + }); + if (result.status !== 0) return null; + return (result.stdout || "").trim(); +} + +function repositoryIndexFailure( + reasonCode: string, + evidence: Record, +): RepositoryIndexRunOutput { + return { + result: { + schema_version: 1, + result_kind: "repository_index", + status: "error", + reason_code: reasonCode, + state_changed: "none", + evidence, + next_command: null, + docs: REPOSITORY_INDEX_RECOVERY_DOC, + }, + exitCode: 1, + }; +} + +function runCurrentRepositoryIndex(): RepositoryIndexRunOutput { + const root = repoRoot(); + if (!root) { + return repositoryIndexFailure("repository_not_found", { + cwd: process.cwd(), + }); } + const head = gitText(["rev-parse", "--verify", "HEAD"], root); + const porcelain = gitText( + ["status", "--porcelain=v1", "--untracked-files=all"], + root, + ); + if (!head || porcelain === null) { + return repositoryIndexFailure("repository_state_invalid", { + repository_root: root, + }); + } + const sourceId = deriveCodeSourceId(root); + return runRepositoryIndex({ + root, + sourceId, + head, + workingTreeClean: porcelain === "", + gstackHome: GSTACK_HOME, + baseEnv: process.env, + spawnGbrain: (childArgs, options = {}) => + spawnGbrain(childArgs, { + cwd: options.cwd, + timeout: options.timeout, + baseEnv: options.baseEnv, + }), + }); +} + +function repositoryIndexStage(output: RepositoryIndexRunOutput): StageResult { + const source = + output.result.evidence.source_id ?? + (output.result.evidence.source as { id?: unknown } | undefined)?.id; + return { + name: "code", + ran: true, + ok: output.exitCode === 0, + duration_ms: 0, + summary: `${output.result.reason_code} (${output.result.state_changed})`, + detail: { + ...(typeof source === "string" ? { source_id: source } : {}), + status: output.exitCode === 0 ? "ok" : "failed", + }, + exit_code: output.exitCode, + repository_result: output.result, + }; +} + +function originUrl(repoPath: string): string | null { + return gitText(["remote", "get-url", "origin"], repoPath); } /** @@ -340,14 +556,20 @@ function originUrl(): string | null { * optional interior hyphens. `constrainSourceId` handles the 32-char cap * with a hashed-tail fallback when the combined slug exceeds budget. */ -function deriveCodeSourceId(repoPath: string): string { +export function deriveCodeSourceId(repoPath: string): string { const host = process.env.GSTACK_HOSTNAME || hostname(); - const hostPathHash = createHash("sha1").update(`${host}::${repoPath}`).digest("hex").slice(0, 8); - const remote = canonicalizeRemote(originUrl()); + const hostPathHash = createHash("sha1") + .update(`${host}::${repoPath}`) + .digest("hex") + .slice(0, 8); + const remote = canonicalizeRemote(originUrl(repoPath)); if (remote) { const segs = remote.split("/").filter(Boolean); const slugSource = segs.slice(-2).join("-"); - const fullId = constrainSourceId("gstack-code", `${slugSource}-${hostPathHash}`); + const fullId = constrainSourceId( + "gstack-code", + `${slugSource}-${hostPathHash}`, + ); // If the org+repo+hostpathhash fits cleanly (suffix preserved), use it. if (fullId.endsWith(`-${hostPathHash}`)) return fullId; // Otherwise drop the org prefix and retry with just repo+hostpathhash so @@ -371,7 +593,7 @@ function deriveCodeSourceId(repoPath: string): string { * sync from any worktree of this repo, so users don't accumulate orphans. */ function deriveLegacyCodeSourceId(repoPath: string): string { - const remote = canonicalizeRemote(originUrl()); + const remote = canonicalizeRemote(originUrl(repoPath)); if (remote) { const segs = remote.split("/").filter(Boolean); const slugSource = segs.slice(-2).join("-"); @@ -394,8 +616,11 @@ function deriveLegacyCodeSourceId(repoPath: string): string { * both probes run. */ export function derivePathOnlyHashLegacyId(repoPath: string): string { - const pathHash = createHash("sha1").update(repoPath).digest("hex").slice(0, 8); - const remote = canonicalizeRemote(originUrl()); + const pathHash = createHash("sha1") + .update(repoPath) + .digest("hex") + .slice(0, 8); + const remote = canonicalizeRemote(originUrl(repoPath)); if (remote) { const segs = remote.split("/").filter(Boolean); const slugSource = segs.slice(-2).join("-"); @@ -430,9 +655,10 @@ function gbrainSupportsSourcesRename(env?: NodeJS.ProcessEnv): boolean { const out = `${r.stdout || ""}\n${r.stderr || ""}`; // Match the exact argument shape: `rename ` (with literal // angle brackets in usage strings) or `rename OLD NEW`. - const exact = /sources\s+rename\s+\s+/i.test(out) - || /sources\s+rename\s+OLD\s+NEW/.test(out) - || /sources\s+rename\s+\s+/i.test(out); + const exact = + /sources\s+rename\s+\s+/i.test(out) || + /sources\s+rename\s+OLD\s+NEW/.test(out) || + /sources\s+rename\s+\s+/i.test(out); _gbrainSupportsRenameCache = exact && r.status === 0; } catch { _gbrainSupportsRenameCache = false; @@ -452,11 +678,13 @@ function gbrainSupportsSourcesRename(env?: NodeJS.ProcessEnv): boolean { * older versions returned a flat array. Accept both for forward/backward compat * (mirrors `probeSource`/`sourcePageCount` in lib/gbrain-sources.ts). */ -export function sourceLocalPath(sourceId: string, env?: NodeJS.ProcessEnv): string | null { - const raw = execGbrainJson( - ["sources", "list", "--json"], - { baseEnv: env }, - ); +export function sourceLocalPath( + sourceId: string, + env?: NodeJS.ProcessEnv, +): string | null { + const raw = execGbrainJson(["sources", "list", "--json"], { + baseEnv: env, + }); if (!raw) return null; const found = parseSourcesList(raw).find((s) => s.id === sourceId); return found?.local_path ?? null; @@ -465,7 +693,12 @@ export function sourceLocalPath(sourceId: string, env?: NodeJS.ProcessEnv): stri /** Result of `planHostnameFoldMigration` — informs `runCodeImport` of next steps. */ export type HostnameFoldMigration = | { kind: "none"; reason: "ids-match" | "no-legacy-source" } - | { kind: "skipped-path-drift"; oldId: string; oldPath: string; currentPath: string } + | { + kind: "skipped-path-drift"; + oldId: string; + oldPath: string; + currentPath: string; + } | { kind: "renamed"; oldId: string; newId: string } | { kind: "pending-cleanup"; oldId: string }; @@ -508,7 +741,10 @@ export function planHostnameFoldMigration( }; } if (gbrainSupportsSourcesRename(env)) { - const r = spawnGbrain(["sources", "rename", legacyPathHashId, newSourceId], { baseEnv: env }); + const r = spawnGbrain( + ["sources", "rename", legacyPathHashId, newSourceId], + { baseEnv: env }, + ); if (r.status === 0) { return { kind: "renamed", oldId: legacyPathHashId, newId: newSourceId }; } @@ -533,13 +769,17 @@ export interface GuardedRemoveResult { * caller decides whether a skip is fatal (it never is today — removes are * best-effort cleanup). */ -export function safeSourcesRemove(sourceId: string, env?: NodeJS.ProcessEnv): GuardedRemoveResult { +export function safeSourcesRemove( + sourceId: string, + env?: NodeJS.ProcessEnv, +): GuardedRemoveResult { const ap = detectAutopilot(env); if (ap.active) { return { removed: false, skipped: true, - reason: `autopilot active (${ap.signal}); refusing destructive remove of ${sourceId}. ` + + reason: + `autopilot active (${ap.signal}); refusing destructive remove of ${sourceId}. ` + `Stop autopilot, then re-run /sync-gbrain.`, }; } @@ -548,7 +788,13 @@ export function safeSourcesRemove(sourceId: string, env?: NodeJS.ProcessEnv): Gu return { removed: false, skipped: true, reason: decision.reason }; } const r = spawnGbrain( - ["sources", "remove", sourceId, "--confirm-destructive", ...decision.extraArgs], + [ + "sources", + "remove", + sourceId, + "--confirm-destructive", + ...decision.extraArgs, + ], { baseEnv: env }, ); return { removed: r.status === 0, skipped: false, reason: decision.reason }; @@ -559,7 +805,10 @@ export function safeSourcesRemove(sourceId: string, env?: NodeJS.ProcessEnv): Gu * exist, so the old source is provably redundant before deletion. Routed through * safeSourcesRemove for the #1734 guards. */ -export function removeOrphanedSource(oldId: string, env?: NodeJS.ProcessEnv): boolean { +export function removeOrphanedSource( + oldId: string, + env?: NodeJS.ProcessEnv, +): boolean { return safeSourcesRemove(oldId, env).removed; } @@ -572,15 +821,21 @@ export function removeOrphanedSource(oldId: string, env?: NodeJS.ProcessEnv): bo * mid-word. Inputs like "drummerms-av-sow-wiz-skill-270c0001" produce * "${prefix}-270c0001-", not "${prefix}-kill-270c0001-". */ -function constrainSourceId(prefix: string, raw: string): string { +export function constrainSourceId(prefix: string, raw: string): string { const MAX = 32; - const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + const slug = raw + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); // Empty slug after sanitize (e.g. raw was all non-alnum like "___") would // produce "${prefix}-" which fails gbrain's validator on the trailing // hyphen. Fall back to a deterministic hash of the original input so the // result is stable across runs of the same repo. if (!slug) { - const hash = createHash("sha1").update(raw || "_empty").digest("hex").slice(0, 6); + const hash = createHash("sha1") + .update(raw || "_empty") + .digest("hex") + .slice(0, 6); return `${prefix}-${hash}`; } const full = `${prefix}-${slug}`; @@ -615,28 +870,26 @@ interface LockInfo { function acquireLock(): boolean { mkdirSync(GSTACK_HOME, { recursive: true }); - if (existsSync(LOCK_PATH)) { - // Check if stale. - try { - const stat = statSync(LOCK_PATH); - const ageMs = Date.now() - stat.mtimeMs; - if (ageMs > STALE_LOCK_MS) { - // Stale; take over. - unlinkSync(LOCK_PATH); - } else { - return false; - } - } catch { - // Cannot stat; bail conservatively. - return false; - } - } - const info: LockInfo = { pid: process.pid, started_at: new Date().toISOString() }; + const info: LockInfo = { + pid: process.pid, + started_at: new Date().toISOString(), + }; try { - writeFileSync(LOCK_PATH, JSON.stringify(info), { encoding: "utf-8", flag: "wx" }); + // `wx` is the sole ownership transition. Never unlink an existing lock + // here: check-then-delete stale recovery has an unavoidable ABA race in + // which a delayed contender can delete a new live owner's lock. + writeFileSync(LOCK_PATH, JSON.stringify(info), { + encoding: "utf-8", + flag: "wx", + }); return true; - } catch { - return false; + } catch (error) { + const code = + error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; + if (code === "EEXIST") return false; + throw error; } } @@ -675,9 +928,15 @@ export function acquireDreamMarker(): boolean { return false; } } - const info: LockInfo = { pid: process.pid, started_at: new Date().toISOString() }; + const info: LockInfo = { + pid: process.pid, + started_at: new Date().toISOString(), + }; try { - writeFileSync(path, JSON.stringify(info), { encoding: "utf-8", flag: "wx" }); + writeFileSync(path, JSON.stringify(info), { + encoding: "utf-8", + flag: "wx", + }); return true; } catch { return false; @@ -698,7 +957,9 @@ export function releaseDreamMarker(): void { /** Read the pid recorded in a fresh dream marker, for the "already running" message. */ function dreamMarkerPid(): number | null { try { - const info = JSON.parse(readFileSync(dreamMarkerPath(), "utf-8")) as LockInfo; + const info = JSON.parse( + readFileSync(dreamMarkerPath(), "utf-8"), + ) as LockInfo; return typeof info.pid === "number" ? info.pid : null; } catch { return null; @@ -732,11 +993,10 @@ function skipStageForLocalStatus( "no local engine; run /setup-gbrain to add local PGLite for code search", "broken-config": "config at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5", - "broken-db": - "config points at unreachable DB; see /setup-gbrain Step 1.5", + "broken-db": "config points at unreachable DB; see /setup-gbrain Step 1.5", "engine-locked": "PGLite is busy (often held by gbrain serve); stop the holding process or run /sync-gbrain outside the live Claude session, then retry", - "timeout": + timeout: "engine probe timed out; raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow", }; const reason = reasons[status as Exclude]; @@ -763,250 +1023,14 @@ function warnProbeTimeout(stage: "code" | "memory" | "dream"): void { ); } - async function runCodeImport(args: CliArgs): Promise { - const t0 = Date.now(); - const root = repoRoot(); - if (!root) { - return { name: "code", ran: false, ok: true, duration_ms: 0, summary: "skipped (not in git repo)" }; - } - - const sourceId = deriveCodeSourceId(root); - - // dry-run preview always shows the would-do steps, regardless of local - // engine state. Useful for "what would /sync-gbrain do" without probing - // the engine. if (args.mode === "dry-run") { - return { - name: "code", - 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}`, - detail: { source_id: sourceId, source_path: root, status: "skipped" }, - }; - } - - // Split-engine pre-flight (per plan D12): when local engine is not ok, SKIP - // code stage cleanly. Brain-sync stage still runs because it doesn't depend - // on local engine. The /sync-gbrain Step 1.5 pre-flight surfaces the user - // remediation message; this skip just keeps the orchestrator from crashing - // when the local DB is dead. Skipped on --dry-run (above) since dry-run - // never actually probes anything. - const localStatus = localEngineStatus({ noCache: false }); - if (localStatus === "timeout") { - warnProbeTimeout("code"); // #1964: slow-but-healthy — proceed - } else if (localStatus !== "ok") { - return skipStageForLocalStatus("code", localStatus, t0); - } - - // Step 0a: Best-effort cleanup of pre-pathhash legacy source (v1.x form). - // Earlier /sync-gbrain versions registered `gstack-code-` (no path - // suffix). On a multi-worktree repo, those collapsed onto a single id - // with last-sync-wins. Federated search would return stale duplicate - // hits forever if we left the orphan in place. Remove the legacy id once - // here so users don't accumulate orphans. - // Failure is non-fatal — we still register the new id below. - // 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) { - // #1734: route through the data-loss guards (autopilot + source-safety). - const rm = safeSourcesRemove(legacyId, gbrainEnv); - if (rm.skipped && !args.quiet) { - console.error(`[sync:code] legacy-source cleanup skipped: ${rm.reason}`); - } - if (rm.removed) legacyRemoved = true; - } - - // Step 0b: Hostname-fold migration (#1414). - // Before #1468 the source id hashed only the absolute repo path. After the - // hostname fold, every existing user has a legacy id that no longer matches - // what deriveCodeSourceId produces. Try rename-in-place first (preserves - // 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); - if (migration.kind === "skipped-path-drift" && !args.quiet) { - console.error( - `[sync:code] hostname-fold migration skipped: legacy source ${migration.oldId} ` - + `points at ${migration.oldPath}, current repo is ${migration.currentPath}. ` - + `Clean up manually with: gbrain sources remove ${migration.oldId} --confirm-destructive`, - ); - } else if (migration.kind === "renamed" && !args.quiet) { - 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). - 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" }, - }; - } - - // Step 2: Always run the page-creating file walk first, then (for --full) - // a full re-embed. - // - // `gbrain reindex-code` only RE-EMBEDS pages that already exist; it never - // walks the filesystem. On a freshly-registered source (0 pages) a --full - // run that called reindex-code alone found nothing ("No code pages to - // reindex"), finished in ~1s, and left the code index permanently empty - // while still reporting OK. The page-creating walk is `sync --strategy - // code`, so --full must run it FIRST, then reindex-code, to honor the - // documented "full walk + reindex" contract for both fresh and populated - // sources. - const codeTimeoutMs = resolveStageTimeoutMs( - process.env.GSTACK_SYNC_CODE_TIMEOUT_MS, - "GSTACK_SYNC_CODE_TIMEOUT_MS", - ); - - // #1734 guards, checked immediately before the destructive walk (E8): - // - autopilot active → refuse (the race that wiped a working tree). - // - URL-managed source → the walk can auto-reclone (rm-rf); require - // --allow-reclone. Both surface a visible reason and fail the stage so the - // verdict shows ERR rather than silently skipping protection. - const apBeforeWalk = detectAutopilot(gbrainEnv); - if (apBeforeWalk.active) { - return { - name: "code", ran: true, ok: false, duration_ms: Date.now() - t0, - summary: `refused: gbrain autopilot active (${apBeforeWalk.signal}). Stop autopilot, then re-run /sync-gbrain.`, - detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" }, - }; - } - const reclone = decideCodeSync(sourceId, gbrainEnv, args.allowReclone); - if (!reclone.allow) { - return { - name: "code", ran: true, ok: false, duration_ms: Date.now() - t0, - summary: `refused: ${reclone.reason}`, - detail: { source_id: sourceId, source_path: root, status: "refused-reclone" }, - }; - } - - const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], { - stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], - timeout: codeTimeoutMs, - baseEnv: gbrainEnv, - }); - - if (walkResult.status !== 0) { - return { - name: "code", - ran: true, - ok: false, - duration_ms: Date.now() - t0, - summary: `gbrain sync --strategy code --source ${sourceId} exited ${walkResult.status}`, - detail: { source_id: sourceId, source_path: root, status: "failed" }, - }; - } - - if (args.mode === "full") { - const reindexResult = spawnGbrain(["reindex-code", "--source", sourceId, "--yes"], { - stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], - timeout: codeTimeoutMs, - baseEnv: gbrainEnv, + return repositoryIndexStage({ + result: orchestrationPreviewResult(), + exitCode: 0, }); - - if (reindexResult.status !== 0) { - return { - name: "code", - ran: true, - ok: false, - duration_ms: Date.now() - t0, - summary: `gbrain reindex-code --source ${sourceId} exited ${reindexResult.status}`, - detail: { source_id: sourceId, source_path: root, status: "failed" }, - }; - } - } - - // Step 3: Pin this worktree's CWD to the source via .gbrain-source. Subsequent - // gbrain code-def / code-refs / code-callers calls from anywhere under - // route to this source by default — no --source flag needed. - // - // If attach fails the whole flow has a silent correctness problem: sync - // succeeded but unqualified `gbrain code-def` from this worktree will hit - // the wrong/default source. Treat it as a stage failure (ok=false) so the - // verdict block surfaces ERR and the user knows to retry rather than - // trusting stale results. - const attach = spawnGbrain(["sources", "attach", sourceId], { - timeout: 10_000, - cwd: root, - baseEnv: gbrainEnv, - }); - const pageCount = sourcePageCount(sourceId, gbrainEnv); - - // Step 4: Deferred hostname-fold cleanup. - // Only remove the pre-#1468 path-only-hash source NOW that the new source - // has registered + synced + has pages. Removing before sync would create a - // data-loss window if sync failed; removing without a page-count check would - // wipe pages when sync silently no-op'd. This is the codex-review-flagged - // safety: register → sync → verify → THEN delete. - let hostnameLegacyRemoved = false; - if (migration.kind === "pending-cleanup" && pageCount !== null && pageCount > 0) { - hostnameLegacyRemoved = removeOrphanedSource(migration.oldId, gbrainEnv); - if (hostnameLegacyRemoved && !args.quiet) { - console.error(`[sync:code] hostname-fold migration: removed legacy ${migration.oldId} after new source sync verified (page_count=${pageCount})`); - } } - - const legacyParts: string[] = []; - if (legacyRemoved) legacyParts.push(`removed legacy ${legacyId}`); - if (migration.kind === "renamed") legacyParts.push(`renamed ${migration.oldId}→${migration.newId}`); - if (hostnameLegacyRemoved) legacyParts.push(`removed pre-hostname-fold ${migration.kind === "pending-cleanup" ? migration.oldId : ""}`); - const legacyNote = legacyParts.length > 0 ? `, ${legacyParts.join(", ")}` : ""; - const baseSummary = `${registered ? "registered + " : ""}synced ${sourceId} (page_count=${pageCount ?? "unknown"}${legacyNote})`; - - if (attach.status !== 0) { - const reason = (attach.stderr || attach.stdout || "").trim().split("\n").pop() || `exit ${attach.status}`; - return { - name: "code", - ran: true, - ok: false, - duration_ms: Date.now() - t0, - summary: `${baseSummary}; attach FAILED (${reason}) — code-def queries from this worktree will hit the default source until /sync-gbrain succeeds`, - detail: { - source_id: sourceId, - source_path: root, - page_count: pageCount, - last_imported: new Date().toISOString(), - status: "failed", - }, - }; - } - - // v1.29.0.0 changelog promised the per-worktree pin would be ignored in the - // consuming repo, but the change actually only added .gbrain-source to - // gstack's own .gitignore. Without the consumer-side entry, the pin gets - // committed and breaks the per-worktree promise: Conductor sibling worktrees - // step on each other's pin every time anyone commits (#1384). - ensureGbrainSourceGitignored(root); - - return { - name: "code", - ran: true, - ok: true, - duration_ms: Date.now() - t0, - summary: baseSummary, - detail: { - source_id: sourceId, - source_path: root, - page_count: pageCount, - last_imported: new Date().toISOString(), - status: "ok", - }, - }; + return repositoryIndexStage(runCurrentRepositoryIndex()); } /** @@ -1046,7 +1070,13 @@ function runMemoryIngest(args: CliArgs): StageResult { const t0 = Date.now(); if (args.mode === "dry-run") { - return { name: "memory", ran: false, ok: true, duration_ms: 0, summary: "would: gstack-memory-ingest --probe" }; + return { + name: "memory", + ran: false, + ok: true, + duration_ms: 0, + summary: "would: gstack-memory-ingest --probe", + }; } // Split-engine pre-flight (per plan D12). gstack-memory-ingest shells out @@ -1137,23 +1167,39 @@ function runBrainSyncPush(args: CliArgs): StageResult { const t0 = Date.now(); if (args.mode === "dry-run") { - return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "would: gstack-brain-sync --discover-new --once" }; + return { + name: "brain-sync", + ran: false, + ok: true, + duration_ms: 0, + summary: "would: gstack-brain-sync --discover-new --once", + }; } const brainSyncPath = join(import.meta.dir, "gstack-brain-sync"); if (!existsSync(brainSyncPath)) { - return { name: "brain-sync", ran: false, ok: true, duration_ms: 0, summary: "skipped (gstack-brain-sync not installed)" }; + 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"], + 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"], + stdio: args.quiet + ? ["ignore", "ignore", "ignore"] + : ["ignore", "inherit", "inherit"], timeout: 60 * 1000, shell: NEEDS_SHELL_ON_WINDOWS, }); @@ -1163,7 +1209,10 @@ function runBrainSyncPush(args: CliArgs): StageResult { ran: true, ok: result.status === 0, duration_ms: Date.now() - t0, - summary: result.status === 0 ? "curated artifacts pushed" : `gstack-brain-sync exited ${result.status}`, + summary: + result.status === 0 + ? "curated artifacts pushed" + : `gstack-brain-sync exited ${result.status}`, }; } @@ -1180,7 +1229,10 @@ function runBrainSyncPush(args: CliArgs): StageResult { * * `cycle` is only consulted on the --full auto path; pass null when forcing. */ -export function shouldRunDream(args: CliArgs, cycle: CycleStatus | null): boolean { +export function shouldRunDream( + args: CliArgs, + cycle: CycleStatus | null, +): boolean { if (args.dream) return true; if (args.mode === "full" && !args.noDream && !args.noCode) { return cycle === "never"; @@ -1264,7 +1316,9 @@ export async function runDream(args: CliArgs): Promise { // alone would falsely report "call graph built". Trade-off: no live streaming // for a long cycle; we echo the captured output afterward instead. if (!args.quiet) { - process.stderr.write("[dream] running gbrain cycle (call-graph build; this can take a few minutes)...\n"); + process.stderr.write( + "[dream] running gbrain cycle (call-graph build; this can take a few minutes)...\n", + ); } let result: ReturnType; try { @@ -1321,7 +1375,8 @@ export async function runDream(args: CliArgs): Promise { ran: false, ok: true, duration_ms: Date.now() - t0, - summary: "skipped — a gbrain cycle is already running (e.g. autopilot); the call graph builds on that cycle", + summary: + "skipped — a gbrain cycle is already running (e.g. autopilot); the call graph builds on that cycle", }; } @@ -1463,7 +1518,8 @@ function mergeDreamIntoState(dream: StageResult): void { export function formatStage(s: StageResult): string { const status = !s.ran ? "SKIP" : !s.ok ? "ERR" : s.warn ? "WARN" : "OK"; - const dur = s.duration_ms > 0 ? ` (${(s.duration_ms / 1000).toFixed(1)}s)` : ""; + const dur = + s.duration_ms > 0 ? ` (${(s.duration_ms / 1000).toFixed(1)}s)` : ""; return ` ${status.padEnd(5)} ${s.name.padEnd(12)} ${s.summary}${dur}`; } @@ -1472,7 +1528,37 @@ export function formatStage(s: StageResult): string { async function main(): Promise { const args = parseArgs(); - if (!args.quiet) { + if (args.verifyReceipt) { + const root = repoRoot(); + const output = root + ? verifyCurrentRepositoryIndexReceipt(root, GSTACK_HOME) + : repositoryIndexFailure("repository_not_found", { + cwd: process.cwd(), + }); + if (args.json) { + console.log(JSON.stringify(output.result)); + } else { + const rendered = renderRepositoryIndexResult(output.result); + if (output.exitCode === 0) console.log(rendered); + else console.error(rendered); + } + process.exit(output.exitCode); + } + + // Wrapper dry-run is deliberately a no-probe assurance level. Return before + // engine detection, lock acquisition, Git inspection, or any gbrain spawn; + // a validated content plan belongs to the compatible child. + if (args.mode === "dry-run") { + const preview = orchestrationPreviewResult(); + console.log( + args.json + ? JSON.stringify(preview) + : renderRepositoryIndexResult(preview), + ); + process.exit(0); + } + + if (!args.quiet && !args.json) { const engine = detectEngineTier(); console.error(`[gbrain-sync] mode=${args.mode} engine=${engine.engine}`); } @@ -1483,10 +1569,21 @@ async function main(): Promise { if (needsLock) { haveLock = acquireLock(); if (!haveLock) { - console.error( - `[gbrain-sync] another /sync-gbrain is running (lock at ${LOCK_PATH}). ` + - `If that process died, the lock auto-clears after 5 min, or remove it manually.` - ); + const lockBusy: RepositoryIndexResult = { + schema_version: 1, + result_kind: "repository_index", + status: "incomplete", + reason_code: "lock_busy", + state_changed: "none", + evidence: { lock_path: LOCK_PATH }, + next_command: "/sync-gbrain --code-only", + docs: REPOSITORY_INDEX_RECOVERY_DOC, + }; + if (args.json) { + console.log(JSON.stringify(lockBusy)); + } else { + console.error(renderRepositoryIndexResult(lockBusy)); + } process.exit(2); } } @@ -1494,25 +1591,82 @@ async function main(): Promise { const cleanup = () => { if (haveLock) releaseLock(); }; - process.on("SIGINT", () => { cleanup(); process.exit(130); }); - process.on("SIGTERM", () => { cleanup(); process.exit(143); }); + process.once("SIGINT", () => { + cleanup(); + process.exit(130); + }); + process.once("SIGTERM", () => { + cleanup(); + process.exit(143); + }); + + if (args.json) { + let output: RepositoryIndexRunOutput; + try { + output = args.noCode + ? repositoryIndexFailure("repository_stage_disabled", { + option: "--no-code", + }) + : runCurrentRepositoryIndex(); + } catch (error) { + output = repositoryIndexFailure("repository_index_failed", { + detail: error instanceof Error ? error.message : String(error), + }); + } finally { + cleanup(); + } + console.log(JSON.stringify(output.result)); + process.exit(output.exitCode); + } let exitCode = 0; const stages: StageResult[] = []; + let repositoryIndexExit: 0 | 1 | 2 = 0; try { const state = loadSyncState(); if (!args.noCode) { - stages.push(await withErrorContext("sync:code", () => runCodeImport(args), "gstack-gbrain-sync")); + stages.push( + await withErrorContext( + "sync:code", + () => runCodeImport(args), + "gstack-gbrain-sync", + ), + ); + const code = stages[stages.length - 1]; + repositoryIndexExit = code.exit_code ?? (code.ok ? 0 : 1); + if ( + code.repository_result && + (!args.quiet || repositoryIndexExit !== 0) + ) { + const rendered = renderRepositoryIndexResult(code.repository_result); + if (repositoryIndexExit === 0) console.log(rendered); + else console.error(rendered); + } } - if (!args.noMemory) { - stages.push(await withErrorContext("sync:memory", () => runMemoryIngest(args), "gstack-gbrain-sync")); + // Registration, refusal, partial apply, or unverified apply is a terminal + // repository-index boundary. Do not continue into unrelated stages or + // write the legacy aggregate state as though the whole sync proceeded. + if (repositoryIndexExit === 0 && !args.noMemory) { + stages.push( + await withErrorContext( + "sync:memory", + () => runMemoryIngest(args), + "gstack-gbrain-sync", + ), + ); } - if (!args.noBrainSync) { - stages.push(await withErrorContext("sync:brain-sync", () => runBrainSyncPush(args), "gstack-gbrain-sync")); + if (repositoryIndexExit === 0 && !args.noBrainSync) { + stages.push( + await withErrorContext( + "sync:brain-sync", + () => runBrainSyncPush(args), + "gstack-gbrain-sync", + ), + ); } - if (args.mode !== "dry-run") { + if (repositoryIndexExit === 0) { state.last_sync = new Date().toISOString(); if (args.mode === "full") state.last_full_sync = state.last_sync; state.last_stages = stages; @@ -1520,7 +1674,8 @@ async function main(): Promise { } const anyError = stages.some((s) => s.ran && !s.ok); - exitCode = anyError ? 1 : 0; + exitCode = + repositoryIndexExit !== 0 ? repositoryIndexExit : anyError ? 1 : 0; } finally { // Release the sync lock BEFORE the dream cycle. Dream is a source-scoped // cycle that can run several minutes; holding the machine-wide lock that @@ -1531,20 +1686,15 @@ async function main(): Promise { // ── Dream (call-graph build) — LOCK-FREE, after the sync lock releases ───── let dreamStage: StageResult | null = null; - if (args.mode === "dry-run") { - // Preview only; never probes doctor or spawns. `--dry-run` and `--full` are - // mutually exclusive modes (last one wins in parseArgs), so the only dream - // preview that applies to a dry-run is the explicit --dream force. - if (args.dream) { - dreamStage = await runDream(args); - } - } else { + if (repositoryIndexExit === 0) { // Resolve cycle state only on the --full auto path (perf: the steady-state // incremental sync never pays a doctor subprocess). Explicit --dream forces. 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"; + cycle = root + ? cycleCompleted(deriveCodeSourceId(root), process.env) + : "unknown"; } if (shouldRunDream(args, cycle)) { dreamStage = await runDream(args); @@ -1560,7 +1710,8 @@ async function main(): Promise { ran: false, ok: true, duration_ms: 0, - summary: "call-graph state unknown (doctor unavailable) — run /sync-gbrain --dream if code-callers returns 0", + summary: + "call-graph state unknown (doctor unavailable) — run /sync-gbrain --dream if code-callers returns 0", }; } } @@ -1571,7 +1722,9 @@ async function main(): Promise { for (const s of allStages) console.log(formatStage(s)); const okCount = allStages.filter((s) => s.ok).length; const errCount = allStages.filter((s) => !s.ok && s.ran).length; - console.log(`\n ${okCount} ok, ${errCount} error, ${allStages.length - okCount - errCount} skipped`); + console.log( + `\n ${okCount} ok, ${errCount} error, ${allStages.length - okCount - errCount} skipped`, + ); } process.exit(exitCode); @@ -1579,7 +1732,26 @@ async function main(): Promise { if (import.meta.main) { main().catch((err) => { - console.error(`gstack-gbrain-sync fatal: ${err instanceof Error ? err.message : String(err)}`); + const detail = err instanceof Error ? err.message : String(err); + if (process.argv.slice(2).includes("--json")) { + process.stdout.write( + `${JSON.stringify( + repositoryIndexFailure( + err instanceof CliArgumentError + ? "invalid_arguments" + : "repository_index_failed", + { detail }, + ).result, + )}\n`, + ); + } else { + if (err instanceof CliArgumentError) { + console.error(detail); + printUsage(); + } else { + console.error(`gstack-gbrain-sync fatal: ${detail}`); + } + } releaseLock(); process.exit(1); }); diff --git a/browse/SKILL.md b/browse/SKILL.md index a8138dbbc3..c4576f0690 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -341,33 +341,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/canary/SKILL.md b/canary/SKILL.md index 08d4d7369e..68ef050e3a 100644 --- a/canary/SKILL.md +++ b/canary/SKILL.md @@ -466,33 +466,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/codex/SKILL.md b/codex/SKILL.md index 33228ff9b8..dfa7577d7a 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -469,33 +469,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/context-restore/SKILL.md b/context-restore/SKILL.md index 59b40e82c2..b97e58a09e 100644 --- a/context-restore/SKILL.md +++ b/context-restore/SKILL.md @@ -470,33 +470,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/context-save/SKILL.md b/context-save/SKILL.md index a1eb245950..05e007eb73 100644 --- a/context-save/SKILL.md +++ b/context-save/SKILL.md @@ -469,33 +469,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/cso/SKILL.md b/cso/SKILL.md index a08d7e9fe2..890bfe23b0 100644 --- a/cso/SKILL.md +++ b/cso/SKILL.md @@ -472,33 +472,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/design-consultation/SKILL.md b/design-consultation/SKILL.md index 83eed0a2d9..efff81c0b3 100644 --- a/design-consultation/SKILL.md +++ b/design-consultation/SKILL.md @@ -492,33 +492,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/design-html/SKILL.md b/design-html/SKILL.md index a480bd62c4..959e6ed98b 100644 --- a/design-html/SKILL.md +++ b/design-html/SKILL.md @@ -473,33 +473,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/design-review/SKILL.md b/design-review/SKILL.md index 6454531625..b667651251 100644 --- a/design-review/SKILL.md +++ b/design-review/SKILL.md @@ -470,33 +470,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/design-shotgun/SKILL.md b/design-shotgun/SKILL.md index 3386d18fa5..9d4babe437 100644 --- a/design-shotgun/SKILL.md +++ b/design-shotgun/SKILL.md @@ -487,33 +487,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/devex-review/SKILL.md b/devex-review/SKILL.md index 7ef324b3ec..cbbffbcb5d 100644 --- a/devex-review/SKILL.md +++ b/devex-review/SKILL.md @@ -472,33 +472,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/diagram/SKILL.md b/diagram/SKILL.md index 9e5a41066c..e53c8e4680 100644 --- a/diagram/SKILL.md +++ b/diagram/SKILL.md @@ -467,33 +467,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/docs/gbrain-sync-errors.md b/docs/gbrain-sync-errors.md index 7ab50cdfe4..665065e5cf 100644 --- a/docs/gbrain-sync-errors.md +++ b/docs/gbrain-sync-errors.md @@ -1,6 +1,10 @@ -# gbrain-sync error lookup +# GStack artifact-sync error lookup -Every error message `gstack-brain-*` can print, with problem, cause, and fix. +Every artifact-transport error message `gstack-brain-*` can print, with +problem, cause, and fix. This guide does not cover `/sync-gbrain` repository +indexing; use +[Repository index sync and recovery](repository-index-recovery.md) for +version/source/path refusals, partial application, and receipt failures. Search this file by the prefix after `BRAIN_SYNC:` or by the binary name in the command output. diff --git a/docs/gbrain-sync.md b/docs/gbrain-sync.md index 62a12b56a1..c73714987b 100644 --- a/docs/gbrain-sync.md +++ b/docs/gbrain-sync.md @@ -1,10 +1,14 @@ -# Cross-machine memory with GBrain sync +# Cross-machine GStack artifact sync gstack writes a lot of useful state to `~/.gstack/` — learnings, retros, CEO plans, design docs, developer profile. By default, all of that dies when you -switch laptops. **GBrain sync** pushes a curated subset to a private git -repo so your memory follows you across machines and becomes indexable by -GBrain. +switch laptops. **GStack artifact sync** pushes a curated subset to a private +Git repository so your memory follows you across machines. Optional source +wireup can then make that artifact repository indexable by GBrain. + +This is not the current-worktree repository-index stage of `/sync-gbrain`. +For code + tracked Markdown, version/source/path gates, receipts, and recovery, +see [Repository index sync and recovery](repository-index-recovery.md). ## What you get diff --git a/docs/repository-index-recovery.md b/docs/repository-index-recovery.md new file mode 100644 index 0000000000..a7769e7f67 --- /dev/null +++ b/docs/repository-index-recovery.md @@ -0,0 +1,397 @@ +# Repository index sync and recovery + +This guide is for the repository-index stage of `/sync-gbrain`: committed +source code and tracked Markdown from the current Git worktree into one +worktree-scoped GBrain source. + +It is not the cross-machine GStack artifact-sync feature. Artifact sync moves +selected plans, retros, learnings, and profile data from `~/.gstack/` through a +private Git repository; see +[Cross-machine GStack artifact sync](gbrain-sync.md). + +## Safety contract + +Repository indexing requires GBrain `0.42.71.0` or newer. The wrapper compares +all four numeric version components and refuses missing components, +prerelease/build suffixes, or unrelated prose. This is a paired-release floor: +reconfirm it against the actual released GBrain version before adopting the +gstack change. + +The wrapper: + +- reads one strict registered-source snapshot before planning; +- resolves relative, absolute, and symlinked paths to canonical filesystem + identity; +- never fixes path drift by removing and re-adding a source; +- previews one source with strategy `auto`, `--no-pull`, exact target and + bookmark preconditions, and `--require-clean` when the tree is clean, then + applies only with the preview's exact immutable `plan_digest`; +- sets `GBRAIN_EMBEDDING_MULTIMODAL=false` in a copied child environment; +- accepts only one schema-1 GBrain JSON document with a recognized terminal + status; +- re-reads source bookmark and strategy after application, writes the source + marker through a wrapper-owned non-following atomic replacement, then + re-reads the strict source snapshot again; +- writes a fail-closed attempt watermark before the first GBrain probe and + removes any prior receipt, so a failed same-HEAD retry cannot resurrect old + GREEN evidence; +- writes a bounded, atomic receipt only after every postcondition verifies, + while the watermark still blocks readers, then clears the watermark to + reveal the receipt. + +Ordinary repository sync can index both code and tracked Markdown. The legacy +flag `/sync-gbrain --code-only` now means “run only the repository-index stage” +and skips GStack memory/artifact stages; it does not mean GBrain +`--strategy code`. + +## Two preview assurance levels + +### Wrapper dry-run: orchestration only + +```bash +/sync-gbrain --dry-run +``` + +Its heading is exactly: + +```text +ORCHESTRATION PREVIEW — unvalidated +``` + +This mode returns before Git inspection, engine detection, lock acquisition, +source lookup, or a GBrain call. It writes nothing, but it also proves nothing +about the installed version, source, path, bookmark, or content operations. +Machine output uses: + +```json +{ + "schema_version": 1, + "result_kind": "repository_index", + "status": "preview_ready", + "reason_code": "blocked_until_version_proven", + "state_changed": "none", + "preview_kind": "orchestration_unvalidated" +} +``` + +Do not copy an executable raw sync command from this preview. First prove the +installed release and source state. + +### GBrain dry-run: validated content plan + +After version, source, canonical root, target HEAD, and bookmark are known, run +the source-scoped GBrain preview. Read the source id and bookmark from the +just-proven source snapshot. This example collects them without evaluating +pasted text, derives the root and HEAD from Git, validates the expected SHAs, +and quotes every argv value: + +```bash +gbrain_repository_preview() { + unset _GBRAIN_REPAIR_PLAN_DIGEST _GBRAIN_REPAIR_PREVIEW_JSON + printf 'Source id from the proven snapshot: ' >&2 + IFS= read -r SOURCE_ID + printf 'Expected bookmark (40 lowercase hex characters or none): ' >&2 + IFS= read -r EXPECTED_BOOKMARK + REPOSITORY_ROOT=$(git rev-parse --show-toplevel) || return + TARGET_HEAD=$(git rev-parse --verify HEAD) || return + + printf '%s\n' "$SOURCE_ID" | + grep -Eq '^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$' || { + printf 'Source id does not match the strict registered-source format.\n' >&2 + return 1 + } + printf '%s\n' "$TARGET_HEAD" | grep -Eq '^[0-9a-f]{40}$' || { + printf 'Current HEAD is not a full lowercase SHA.\n' >&2 + return 1 + } + if [ "$EXPECTED_BOOKMARK" != "none" ]; then + printf '%s\n' "$EXPECTED_BOOKMARK" | grep -Eq '^[0-9a-f]{40}$' || { + printf 'Expected bookmark must be a full lowercase SHA or none.\n' >&2 + return 1 + } + fi + + _GBRAIN_REPAIR_SOURCE_ID=$SOURCE_ID + _GBRAIN_REPAIR_ROOT=$REPOSITORY_ROOT + _GBRAIN_REPAIR_TARGET_HEAD=$TARGET_HEAD + _GBRAIN_REPAIR_EXPECTED_BOOKMARK=$EXPECTED_BOOKMARK + _GBRAIN_REPAIR_PREVIEW_JSON=$(gbrain sync \ + --strategy auto \ + --source "$SOURCE_ID" \ + --repo "$REPOSITORY_ROOT" \ + --no-pull \ + --expected-target "$_GBRAIN_REPAIR_TARGET_HEAD" \ + --expected-bookmark "$_GBRAIN_REPAIR_EXPECTED_BOOKMARK" \ + --require-clean \ + --no-embed \ + --no-extract \ + --dry-run \ + --json) || return + printf '%s\n' "$_GBRAIN_REPAIR_PREVIEW_JSON" + + _GBRAIN_REPAIR_PLAN_DIGEST=$( + printf '%s\n' "$_GBRAIN_REPAIR_PREVIEW_JSON" | + bun -e ' + const value = JSON.parse(await Bun.stdin.text()); + if ( + value.schema_version !== 1 || + value.result_kind !== "gbrain_sync" || + value.status !== "dry_run" || + value.preview_kind !== "validated_index_plan" || + typeof value.plan_digest !== "string" || + !/^[0-9a-f]{64}$/.test(value.plan_digest) + ) process.exit(1); + process.stdout.write(value.plan_digest); + ' + ) || { + printf 'Preview did not return a valid immutable plan_digest.\n' >&2 + unset _GBRAIN_REPAIR_PLAN_DIGEST + return 1 + } +} + +gbrain_repository_apply_preview() { + if [ -z "${_GBRAIN_REPAIR_PLAN_DIGEST:-}" ] || + [ -z "${_GBRAIN_REPAIR_SOURCE_ID:-}" ] || + [ -z "${_GBRAIN_REPAIR_ROOT:-}" ] || + [ -z "${_GBRAIN_REPAIR_TARGET_HEAD:-}" ] || + [ -z "${_GBRAIN_REPAIR_EXPECTED_BOOKMARK:-}" ]; then + printf 'Run and review gbrain_repository_preview first.\n' >&2 + return 1 + fi + printf '%s\n' "$_GBRAIN_REPAIR_PLAN_DIGEST" | + grep -Eq '^[0-9a-f]{64}$' || { + printf 'Stored preview plan_digest is invalid; preview again.\n' >&2 + unset _GBRAIN_REPAIR_PLAN_DIGEST + return 1 + } + CURRENT_ROOT=$(git rev-parse --show-toplevel) || return + CURRENT_HEAD=$(git rev-parse --verify HEAD) || return + if [ "$CURRENT_ROOT" != "$_GBRAIN_REPAIR_ROOT" ] || + [ "$CURRENT_HEAD" != "$_GBRAIN_REPAIR_TARGET_HEAD" ]; then + printf 'Repository root or HEAD changed after preview; preview again.\n' >&2 + unset _GBRAIN_REPAIR_PLAN_DIGEST + return 1 + fi + + if gbrain sync \ + --strategy auto \ + --source "$_GBRAIN_REPAIR_SOURCE_ID" \ + --repo "$_GBRAIN_REPAIR_ROOT" \ + --no-pull \ + --expected-target "$_GBRAIN_REPAIR_TARGET_HEAD" \ + --expected-bookmark "$_GBRAIN_REPAIR_EXPECTED_BOOKMARK" \ + --require-clean \ + --no-embed \ + --no-extract \ + --expected-plan-digest "$_GBRAIN_REPAIR_PLAN_DIGEST" \ + --json; then + APPLY_STATUS=0 + else + APPLY_STATUS=$? + fi + unset _GBRAIN_REPAIR_PLAN_DIGEST + return "$APPLY_STATUS" +} + +gbrain_repository_preview +``` + +This is the recovery-safe content preview. It reads the configured engine and +may use GBrain's transient source-lock lifecycle, but it must not pull Git, +mutate repository files, apply content/bookmark changes, call image providers, +or run extraction/embedding. It returns a schema-1 +`validated_index_plan` with a 64-character lowercase `plan_digest`. Review its +one-document plan, then invoke `gbrain_repository_apply_preview` in the same +shell for an explicit repair. Never remove `--dry-run` and apply without +passing that exact digest through `--expected-plan-digest`. + +The ordinary wrapper is separate: under its own lock it runs the same strict +preview, validates the complete bounded schema, passes the exact digest to +apply, and requires the successful apply result to echo the same digest. It +never claims to apply a stale or different plan. + +## Normal flow + +Run from the intended worktree: + +```bash +/sync-gbrain --code-only +``` + +The repository stage is terminal on refusal, partial application, or +unverified application. Memory ingest, artifact sync, `.gitignore` changes, +legacy cleanup, reindex, and dream do not continue across that boundary. + +### First invocation: source registration + +If the source does not exist, a real invocation performs only: + +```text +gbrain sources add --path --federated +``` + +It then stops with exit 2: + +```json +{ + "status": "incomplete", + "reason_code": "source_registered", + "state_changed": "registry_only" +} +``` + +No sync or attach occurs. This is intentional: a source cannot have a +bookmark-bound content plan until it exists. + +### Second invocation: expected-state sync + +Run the same wrapper command again. The strict source snapshot can now bind +`--expected-bookmark `. A clean tree adds `--require-clean`; a dirty +tree may be operationally indexed but cannot receive a trusted clean-HEAD +receipt. Before apply, the wrapper requires a schema-1 `dry_run` plan and binds +apply to its full `plan_digest`. + +## Source and path decisions + +| Condition | Result | Persistent effect | Next action | +|---|---|---|---| +| Source absent | `source_registered` | `registry_only` | Run the wrapper again; optionally inspect a validated GBrain content preview first. | +| Stored `.` / relative / absolute / symlink resolves to this root | `equivalent` | No registration rewrite | Continue with expected target/bookmark. | +| Stored path resolves to another directory | `source_path_different` | `none` | Stop. Inspect the source and owner intent; never remove/re-add automatically. | +| Missing, inaccessible, non-directory, or otherwise unprovable path | `source_path_ambiguous` | `none` | Stop and repair filesystem/source metadata under owner review. | +| Windows path contains whitespace or shell metacharacters | `unsupported_path` | `none` | Move/use a safe path or wait for the argv-preserving launcher transport replacement. | +| Source changes during execution | child refusal / `sync_blocked` | Child-reported state | Re-read the source and retry; do not override expected-state guards. | +| Plan changes between preview and apply | `plan_changed` | `none` | Review the returned observed/required digests, then preview again; never reuse the prior digest. | + +Useful read-only inspection: + +```bash +gbrain --version +gbrain sources list --json +git rev-parse --show-toplevel +git rev-parse HEAD +git status --porcelain=v1 --untracked-files=normal +``` + +## Result and state semantics + +For every completed `--json` invocation, the wrapper writes exactly one +schema-1 JSON document on stdout. Its public keys are `result_kind`, +`reason_code`, and `state_changed`. An externally terminated process may not +return a document; absence is always an unverified failure, never success. + +| `state_changed` | Meaning | +|---|---| +| `none` | No durable repository-index state changed. | +| `lock_only` | Only transient lifecycle-lock state may have changed. | +| `registry_only` | Source registration changed; content sync and attach did not run. | +| `partial` | Repository-index support metadata or content may have changed, but no full completion proof exists. | +| `applied_unverified` | Content may be complete, but post-sync source, attach, clean-HEAD, or receipt verification failed. Do not imply rollback. | +| `applied_verified` | Content, source marker, bookmark/strategy, clean HEAD, and atomic receipt all verified. | + +Only GBrain child statuses `synced`, `first_sync`, and `up_to_date` can proceed +to post-apply verification. `partial`, `blocked_by_failures`, a nonzero +unstructured exit, malformed/multiple JSON, wrong producer, or unknown status +cannot attach or produce GREEN. + +## Trusted receipt + +After a clean verified run, the wrapper atomically writes: + +```text +~/.gstack/.gbrain-repository-index-receipt.json +``` + +Before consuming it, bind that persisted evidence to the live worktree: + +```text +/sync-gbrain --verify-receipt --json +``` + +The verifier is read-only and does not contact GBrain. It checks the wrapper +lock and attempt watermark both before and after rebinding the receipt to the +live canonical root, attached source id, clean full HEAD, and tracked-marker +state. An active wrapper returns `receipt_in_progress`. A newer attempt that +did not reach GREEN leaves its watermark and returns `receipt_superseded`; +do not delete that watermark to resurrect historical evidence—run the wrapper +again and let a fully verified result replace it. Never use a receipt to turn +an explicit `--no-code` run GREEN. + +The receipt binds: + +- strict detected and required GBrain versions; +- source id, stored path, canonical root, and `equivalent` identity; +- full Git HEAD, clean-tree state, bookmark before/after, and + `last_successful_strategy: auto`; +- terminal status and added/modified/deleted/renamed counts; +- the full immutable `plan_digest` shared by preview and successful apply; +- at most 100 canonically sorted affected items plus a SHA-256 digest over all + `\t\t\t\n` + tuples; +- tracked-Markdown/code counters when supplied by GBrain; +- zero image operations and disabled multimodal admission; +- embedding, extraction, and search-readiness evidence without overclaiming. + +`image_operations_applied: 0` means this invocation admitted no new image work. It does +not claim that the pre-existing corpus has no image pages. A recovery run with +`--no-embed --no-extract` is content-current but reports semantic phases as +deferred and `search_ready: false`. + +Page count alone is never a trusted repository receipt. + +## Recovery branches + +### Unsupported or mixed versions + +If GBrain is missing, malformed, or below `0.42.71.0`, stop before source +mutation. Upgrade GBrain first, then re-run the validated preview and wrapper. +New gstack with old GBrain must refuse; old gstack with fixed GBrain does not +gain this wrapper contract automatically. + +Until both compatible releases are installed, use repository files and `rg`. +Do not use the previously unsafe raw preview path. + +### Partial or blocked child result + +Do not attach, run cleanup, or declare GREEN. Inspect the structured reason and +source bookmark. Fix the named file/lock/precondition and retry the same +source-scoped command. Never use `--skip-failed`, break-lock, or destructive +source changes as an automatic recovery. + +### Applied but unverified + +`applied_unverified` means the child may already have changed index content. +There is no automatic rollback. Fix the failing postcondition—source +bookmark/strategy reread, `.gbrain-source` attach, clean HEAD, or receipt +write—and re-run verification against the same current state. + +### Known missing tracked Markdown after the fixed release + +1. Commit or otherwise establish the exact intended HEAD. +2. Prove GBrain `>= 0.42.71.0`. +3. Prove the source id, canonical path, and current bookmark. +4. Run `gbrain_repository_preview` above, which performs the validated + `auto --no-pull --no-embed --no-extract --dry-run --json` command and + captures its strict `plan_digest`. +5. Review the affected Markdown operation. +6. In the same shell, run `gbrain_repository_apply_preview`. It keeps the + validated variables and quoted argv and supplies the exact digest through + `--expected-plan-digest`; a changed plan refuses before mutation. Then run + the wrapper to attach and produce its trusted receipt. + +### Rollback + +Reverting gstack changes does not remove or restore derived GBrain content. +Reverting GBrain does not make an old wrapper safe. Roll back the owning +release if required, keep the source registration intact, and use files plus +`rg` until an owner-authorized fixed-version reconciliation is available. + +Never force a remove/re-add to simulate rollback: that destroys the identity +and bookmark evidence needed to understand what happened. + +## When to repeat + +Run `/sync-gbrain` after committed source-code changes or tracked Markdown +changes that should be searchable. A trusted receipt is HEAD-bound; later +commits make it historical evidence, not proof of the new tree. diff --git a/docs/skills.md b/docs/skills.md index 8e8cb7adcc..40be456441 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -44,8 +44,8 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples. | **Multi-AI** | | | | [`/codex`](#codex) | **Second Opinion** | Independent review from OpenAI Codex CLI. Three modes: code review (pass/fail gate), adversarial challenge, and open consultation with session continuity. Cross-model analysis when both `/review` and `/codex` have run. | | [`/pair-agent`](#pair-agent) | **Remote Agent Bridge** | Pair a remote AI agent (OpenClaw, Codex, Cursor, Hermes) with your browser. Scoped tunnel, locked allowlist, session token. | -| [`/setup-gbrain`](#setup-gbrain) | **Memory Sync** | Set up gbrain for cross-machine session memory sync. One command from zero to live. | -| [`/sync-gbrain`](#sync-gbrain) | **Keep Brain Current** | Refresh gbrain against this repo's code; teach the agent when to use `gbrain search`/`code-def` over Grep. Idempotent; safe to re-run. | +| [`/setup-gbrain`](#setup-gbrain) | **GBrain Setup** | Configure gbrain, MCP, and per-repository trust without claiming this worktree's repository index is current. | +| [`/sync-gbrain`](#sync-gbrain) | **Keep Repository Index Current** | Sync code and tracked Markdown through strict version/source/path, expected-state, and preview/apply `plan_digest` gates. A missing source is registered on the first invocation; a second invocation can sync it. Search guidance requires a trusted live HEAD-bound receipt. | | | | | | **Safety & Utility** | | | | [`/careful`](#safety--guardrails) | **Safety Guardrails** | Warns before destructive commands (rm -rf, DROP TABLE, force-push, git reset --hard). Override any warning. Common build cleanups whitelisted. | diff --git a/document-generate/SKILL.md b/document-generate/SKILL.md index 30846fc4dd..79af5b3db8 100644 --- a/document-generate/SKILL.md +++ b/document-generate/SKILL.md @@ -472,33 +472,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/document-release/SKILL.md b/document-release/SKILL.md index b95873625b..edc4b06595 100644 --- a/document-release/SKILL.md +++ b/document-release/SKILL.md @@ -470,33 +470,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/gstack/llms.txt b/gstack/llms.txt index efe522f901..01b4649294 100644 --- a/gstack/llms.txt +++ b/gstack/llms.txt @@ -63,7 +63,7 @@ Conventions: - [/ship](ship/SKILL.md): Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. - [/skillify](skillify/SKILL.md): Codify the most recent successful /scrape flow into a permanent browser-skill on disk. - [/spec](spec/SKILL.md): Turn vague intent into a precise, executable spec in five phases. -- [/sync-gbrain](sync-gbrain/SKILL.md): Keep gbrain current with this repo's code and refresh agent search guidance in CLAUDE.md. +- [/sync-gbrain](sync-gbrain/SKILL.md): Keep gbrain current with this repo's code and tracked Markdown, and refresh agent search guidance in CLAUDE.md. - [/unfreeze](unfreeze/SKILL.md): Clear the freeze boundary set by /freeze, allowing edits to all directories again. ## Browse Commands diff --git a/health/SKILL.md b/health/SKILL.md index e68199dec5..14adecef38 100644 --- a/health/SKILL.md +++ b/health/SKILL.md @@ -468,33 +468,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/investigate/SKILL.md b/investigate/SKILL.md index 5d54b42567..ae9c12a3ad 100644 --- a/investigate/SKILL.md +++ b/investigate/SKILL.md @@ -507,33 +507,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/ios-clean/SKILL.md b/ios-clean/SKILL.md index 6c466896be..e6c27f041d 100644 --- a/ios-clean/SKILL.md +++ b/ios-clean/SKILL.md @@ -470,33 +470,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/ios-design-review/SKILL.md b/ios-design-review/SKILL.md index 904da7589b..929e2eabe0 100644 --- a/ios-design-review/SKILL.md +++ b/ios-design-review/SKILL.md @@ -472,33 +472,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/ios-fix/SKILL.md b/ios-fix/SKILL.md index 3ddae1ac0c..723aa89f5a 100644 --- a/ios-fix/SKILL.md +++ b/ios-fix/SKILL.md @@ -473,33 +473,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md index 24624be5f2..ed70875e20 100644 --- a/ios-qa/SKILL.md +++ b/ios-qa/SKILL.md @@ -476,33 +476,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/ios-sync/SKILL.md b/ios-sync/SKILL.md index 95e0bca0a4..6635b3d415 100644 --- a/ios-sync/SKILL.md +++ b/ios-sync/SKILL.md @@ -470,33 +470,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 54ebf52c0f..4c790f7315 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -465,33 +465,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/landing-report/SKILL.md b/landing-report/SKILL.md index 8f7e6e2103..13a9519243 100644 --- a/landing-report/SKILL.md +++ b/landing-report/SKILL.md @@ -466,33 +466,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/learn/SKILL.md b/learn/SKILL.md index a0c6ae053b..6996520ed4 100644 --- a/learn/SKILL.md +++ b/learn/SKILL.md @@ -468,33 +468,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/lib/gbrain-repository-index.ts b/lib/gbrain-repository-index.ts new file mode 100644 index 0000000000..d14e7d569c --- /dev/null +++ b/lib/gbrain-repository-index.ts @@ -0,0 +1,2736 @@ +/** + * Safety contract for the repository-index stage of /sync-gbrain. + * + * This module intentionally does not use the legacy source helper. Repository + * indexing must never repair path drift by removing and re-adding a source: + * version, one strict source snapshot, canonical path identity, and expected + * Git state are proven before the first content mutation. + */ + +import { + appendFileSync, + closeSync, + constants as fsConstants, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "fs"; +import { createHash, randomBytes } from "crypto"; +import { spawnSync } from "child_process"; +import { dirname, isAbsolute, join, resolve } from "path"; + +export const REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION = "0.42.71.0"; +export const REPOSITORY_INDEX_RECOVERY_DOC = + "docs/repository-index-recovery.md"; +export const REPOSITORY_INDEX_RECEIPT = ".gbrain-repository-index-receipt.json"; +export const REPOSITORY_INDEX_ATTEMPT = ".gbrain-repository-index-attempt.json"; +export const REPOSITORY_INDEX_LOCK = ".sync-gbrain.lock"; +export const AFFECTED_SAMPLE_LIMIT = 100; + +export type ReleasedVersion = readonly [number, number, number, number]; + +export type RepositoryIndexStatus = + "verified" | "preview_ready" | "incomplete" | "refused" | "error"; + +export type RepositoryIndexState = + | "none" + | "lock_only" + | "registry_only" + | "partial" + | "applied_unverified" + | "applied_verified"; + +export interface RepositoryIndexResult { + schema_version: 1; + result_kind: "repository_index"; + status: RepositoryIndexStatus; + reason_code: string; + state_changed: RepositoryIndexState; + preview_kind?: "orchestration_unvalidated"; + evidence: Record; + next_command: string | null; + docs: string; +} + +export interface StrictSourceRow { + readonly id: string; + readonly local_path: string | null; + readonly last_commit: string | null; + readonly last_successful_strategy: "markdown" | "code" | "auto" | null; +} + +export type StrictSourceSnapshot = + { ok: true; rows: readonly StrictSourceRow[] } | { ok: false; error: string }; + +export type PathIdentity = + | { + kind: "equivalent"; + stored_path: string; + resolved_path: string; + canonical_path: string; + } + | { + kind: "different"; + stored_path: string; + resolved_path: string; + canonical_path: string; + stored_canonical_path: string; + } + | { + kind: "ambiguous"; + stored_path: string; + resolved_path: string | null; + canonical_path: string | null; + reason: string; + }; + +export interface AffectedItem { + operation: string; + path: string; + slug: string; + from_path?: string; +} + +export interface AffectedSummary { + total: number; + sample_limit: 100; + sample: AffectedItem[]; + truncated: boolean; + sha256: string; +} + +export interface GbrainSpawnResult { + status: number | null; + stdout?: string | null; + stderr?: string | null; + error?: Error; +} + +export interface GbrainSpawnOptions { + cwd?: string; + timeout?: number; + baseEnv?: NodeJS.ProcessEnv; +} + +export interface RepositoryIndexRunInput { + root: string; + sourceId: string; + head: string; + workingTreeClean: boolean; + gstackHome: string; + /** Caller environment to copy for every child; never mutated in place. */ + baseEnv?: NodeJS.ProcessEnv; + spawnGbrain: ( + args: string[], + options?: GbrainSpawnOptions, + ) => GbrainSpawnResult; + platform?: NodeJS.Platform; + writeReceipt?: (path: string, receipt: RepositoryIndexResult) => void; + writeAttemptMarker?: ( + path: string, + marker: RepositoryIndexAttemptMarker, + ) => void; + clearAttemptMarker?: (path: string) => void; + writeSourceMarker?: (root: string, sourceId: string) => void; + readRepositoryState?: (root: string) => RepositoryState; + readAttachedSource?: (root: string) => AttachedSourceState; +} + +export interface RepositoryIndexAttemptMarker { + schema_version: 1; + result_kind: "repository_index_attempt"; + status: "in_progress"; + source_id: string; + repository_root: string; + git_head: string; + started_at: string; +} + +export interface RepositoryIndexRunOutput { + result: RepositoryIndexResult; + exitCode: 0 | 1 | 2; +} + +export interface RepositoryState { + head: string | null; + head_after: string | null; + stable: boolean; + clean: boolean; + porcelain: string | null; + source_marker_tracked: boolean | null; +} + +export interface AttachedSourceState { + present: boolean; + sourceId: string | null; + trustworthy: boolean; + detail: string | null; +} + +const SHA_40 = /^[0-9a-f]{40}$/; +const SHA_256 = /^[0-9a-f]{64}$/; +const SOURCE_ID = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/; +const SOURCE_MARKER_MAX_BYTES = 34; +const CHILD_TERMINAL_STATUSES = new Set(["synced", "first_sync", "up_to_date"]); +const CHILD_INCOMPLETE_STATUSES = new Set(["partial", "blocked_by_failures"]); +const AFFECTED_OPERATIONS = new Set(["add", "modify", "delete", "rename"]); +const CHILD_ERROR_REASONS = new Set([ + "source_changed", + "target_changed", + "bookmark_changed", + "working_tree_dirty", + "managed_clone_missing", + "plan_failed", + "plan_changed", + "dry_run_modifier_conflict", + "lock_busy", + "lock_release_failed", + "embedding_credentials_missing", + "cost_gate_stopped", +]); +const CORPUS_RECEIPT_FIELDS = [ + "code_deletions_applied", + "code_pages_after", + "code_pages_before", + "embedding_status", + "extraction_status", + "image_operations_applied", + "image_pages_after", + "markdown_planned_or_applied", + "multimodal_enabled", + "search_ready", +] as const; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function nonNegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function validCorpusReceipt(value: unknown): value is Record { + if (!isRecord(value)) return false; + const corpusKeys = Object.keys(value).sort(); + if ( + corpusKeys.length !== CORPUS_RECEIPT_FIELDS.length || + corpusKeys.some((key, index) => key !== CORPUS_RECEIPT_FIELDS[index]) + ) { + return false; + } + return ( + nonNegativeInteger(value.markdown_planned_or_applied) && + nonNegativeInteger(value.code_pages_before) && + nonNegativeInteger(value.code_pages_after) && + nonNegativeInteger(value.code_deletions_applied) && + value.image_operations_applied === 0 && + nonNegativeInteger(value.image_pages_after) && + value.multimodal_enabled === false && + (value.embedding_status === "deferred" || + value.embedding_status === "complete") && + (value.extraction_status === "deferred" || + value.extraction_status === "complete") && + typeof value.search_ready === "boolean" && + value.search_ready === + (value.embedding_status === "complete" && + value.extraction_status === "complete") + ); +} + +function validChildErrorTuple( + reasonCode: string, + status: unknown, + stateChanged: unknown, +): boolean { + if (!CHILD_ERROR_REASONS.has(reasonCode)) return false; + if (reasonCode === "lock_release_failed") { + return status === "error" && stateChanged === "lock_only"; + } + if (reasonCode === "plan_failed") { + return ( + (status === "refused" && stateChanged === "none") || + (status === "error" && + (stateChanged === "none" || stateChanged === "partial")) + ); + } + return status === "refused" && stateChanged === "none"; +} + +function repositoryResult( + status: RepositoryIndexStatus, + reasonCode: string, + stateChanged: RepositoryIndexState, + evidence: Record, + nextCommand: string | null = null, +): RepositoryIndexResult { + return { + schema_version: 1, + result_kind: "repository_index", + status, + reason_code: reasonCode, + state_changed: stateChanged, + evidence, + next_command: nextCommand, + docs: REPOSITORY_INDEX_RECOVERY_DOC, + }; +} + +/** + * Parse the released gbrain form only: four numeric components, optionally + * preceded by the CLI's documented `gbrain ` label. Prerelease/build suffixes, + * `v` prefixes, prose, and missing components are rejected. + */ +export function parseReleasedGbrainVersion( + raw: string, +): ReleasedVersion | null { + const match = raw.trim().match(/^(?:gbrain )?(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (!match) return null; + const parts = match.slice(1).map((part) => Number(part)); + if ( + parts.length !== 4 || + parts.some((part) => !Number.isSafeInteger(part) || part < 0) + ) { + return null; + } + return parts as unknown as ReleasedVersion; +} + +export function compareReleasedVersions( + left: ReleasedVersion, + right: ReleasedVersion, +): -1 | 0 | 1 { + for (let index = 0; index < 4; index++) { + if (left[index] < right[index]) return -1; + if (left[index] > right[index]) return 1; + } + return 0; +} + +export function isReleasedVersionAtLeast( + detected: string, + required: string = REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION, +): boolean { + const detectedVersion = parseReleasedGbrainVersion(detected); + const requiredVersion = parseReleasedGbrainVersion(required); + return ( + detectedVersion !== null && + requiredVersion !== null && + compareReleasedVersions(detectedVersion, requiredVersion) >= 0 + ); +} + +/** + * Accept only the two released `sources list --json` containers and a unique, + * complete row set. A malformed row cannot be reinterpreted as source absence. + */ +export function parseStrictSourceSnapshot(raw: unknown): StrictSourceSnapshot { + let candidate: unknown; + if (Array.isArray(raw)) { + candidate = raw; + } else if ( + isRecord(raw) && + Object.keys(raw).length === 1 && + Array.isArray(raw.sources) + ) { + candidate = raw.sources; + } else { + return { + ok: false, + error: "expected a bare source array or exact {sources:[...]} wrapper", + }; + } + + const rows: StrictSourceRow[] = []; + const ids = new Set(); + for (let index = 0; index < candidate.length; index++) { + const row = candidate[index]; + if (!isRecord(row)) { + return { ok: false, error: `source row ${index} is not an object` }; + } + if (typeof row.id !== "string" || row.id.trim() === "") { + return { ok: false, error: `source row ${index} has no valid id` }; + } + if ( + row.local_path !== null && + (typeof row.local_path !== "string" || row.local_path.trim() === "") + ) { + return { + ok: false, + error: `source row ${index} has no valid local_path`, + }; + } + if (!Object.hasOwn(row, "last_commit")) { + return { + ok: false, + error: `source row ${index} has no last_commit evidence`, + }; + } + if (ids.has(row.id)) { + return { ok: false, error: `duplicate source id: ${row.id}` }; + } + if ( + row.last_commit !== undefined && + row.last_commit !== null && + (typeof row.last_commit !== "string" || !SHA_40.test(row.last_commit)) + ) { + return { + ok: false, + error: `source row ${index} has an invalid last_commit`, + }; + } + if (!Object.hasOwn(row, "last_successful_strategy")) { + return { + ok: false, + error: `source row ${index} has no last_successful_strategy evidence`, + }; + } + if ( + row.last_successful_strategy !== null && + row.last_successful_strategy !== "markdown" && + row.last_successful_strategy !== "code" && + row.last_successful_strategy !== "auto" + ) { + return { + ok: false, + error: `source row ${index} has an invalid last_successful_strategy`, + }; + } + ids.add(row.id); + rows.push( + Object.freeze({ + id: row.id, + local_path: row.local_path as string | null, + last_commit: + typeof row.last_commit === "string" ? row.last_commit : null, + last_successful_strategy: row.last_successful_strategy as + "markdown" | "code" | "auto" | null, + }), + ); + } + + return { ok: true, rows: Object.freeze(rows) }; +} + +/** + * Resolve relative stored paths against the proven repository root, then use + * filesystem canonical identity so `.`, absolute spellings, and symlinks can + * compare equal without rewriting source registration. + */ +export function classifyRepositoryPath( + storedPath: string, + repositoryRoot: string, + platform: NodeJS.Platform = process.platform, +): PathIdentity { + const resolvedStored = isAbsolute(storedPath) + ? resolve(storedPath) + : resolve(repositoryRoot, storedPath); + let canonicalRoot: string; + try { + if (!statSync(repositoryRoot).isDirectory()) { + return { + kind: "ambiguous", + stored_path: storedPath, + resolved_path: resolvedStored, + canonical_path: null, + reason: "repository root is not a directory", + }; + } + canonicalRoot = realpathSync.native(repositoryRoot); + } catch (error) { + return { + kind: "ambiguous", + stored_path: storedPath, + resolved_path: resolvedStored, + canonical_path: null, + reason: `repository root cannot be canonicalized: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + let canonicalStored: string; + try { + if (!statSync(resolvedStored).isDirectory()) { + return { + kind: "ambiguous", + stored_path: storedPath, + resolved_path: resolvedStored, + canonical_path: canonicalRoot, + reason: "stored source path is not a directory", + }; + } + canonicalStored = realpathSync.native(resolvedStored); + } catch (error) { + return { + kind: "ambiguous", + stored_path: storedPath, + resolved_path: resolvedStored, + canonical_path: canonicalRoot, + reason: `stored source path cannot be canonicalized: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + const compare = (value: string) => + platform === "win32" ? value.toLocaleLowerCase("en-US") : value; + if (compare(canonicalStored) === compare(canonicalRoot)) { + return { + kind: "equivalent", + stored_path: storedPath, + resolved_path: resolvedStored, + canonical_path: canonicalRoot, + }; + } + return { + kind: "different", + stored_path: storedPath, + resolved_path: resolvedStored, + canonical_path: canonicalRoot, + stored_canonical_path: canonicalStored, + }; +} + +function equivalentSourceOwnerIds( + rows: readonly StrictSourceRow[], + repositoryRoot: string, + platform: NodeJS.Platform, +): string[] { + return rows + .filter((row) => row.local_path !== null) + .filter( + (row) => + classifyRepositoryPath(row.local_path!, repositoryRoot, platform) + .kind === "equivalent", + ) + .map((row) => row.id) + .sort(); +} + +/** + * gbrain currently needs shell:true on Windows for its .cmd launcher. Until + * that transport is replaced, refuse repository roots containing whitespace + * (which shell:true splits) or cmd.exe metacharacters instead of pretending + * the child received an exact argv. + */ +export function unsafeRepositoryPathForShell( + repositoryRoot: string, + platform: NodeJS.Platform = process.platform, +): boolean { + return platform === "win32" && /[\s&|^%!()"'<>\r\n]/.test(repositoryRoot); +} + +function canonicalAffectedItem( + item: AffectedItem, + strictPosixInput = false, +): AffectedItem | null { + const controlCharacter = /[\u0000-\u001f\u007f]/u; + if ( + !AFFECTED_OPERATIONS.has(item.operation) || + typeof item.path !== "string" || + typeof item.slug !== "string" || + item.slug.length === 0 || + controlCharacter.test(item.path) || + controlCharacter.test(item.slug) || + (strictPosixInput && + (item.path.includes("\\") || + item.slug.includes("\\") || + item.from_path?.includes("\\") === true)) + ) { + return null; + } + const path = item.path.replaceAll("\\", "/"); + const fromPath = + item.from_path === undefined + ? undefined + : item.from_path.replaceAll("\\", "/"); + const invalidPath = (value: string) => + value.length === 0 || + controlCharacter.test(value) || + value.startsWith("/") || + /^[A-Za-z]:\//.test(value) || + value + .split("/") + .some((part) => part === "" || part === "." || part === ".."); + if (invalidPath(path) || (fromPath !== undefined && invalidPath(fromPath))) { + return null; + } + return { + operation: item.operation, + path, + slug: item.slug, + ...(fromPath === undefined ? {} : { from_path: fromPath }), + }; +} + +function affectedTuple(item: AffectedItem): string { + return `${item.operation}\t${item.path}\t${item.slug}\t${item.from_path ?? ""}`; +} + +function bytewiseCompare(left: string, right: string): number { + return Buffer.compare( + Buffer.from(left, "utf-8"), + Buffer.from(right, "utf-8"), + ); +} + +export function summarizeAffectedItems( + items: readonly AffectedItem[], +): AffectedSummary { + const canonical = items.map(canonicalAffectedItem); + if (canonical.some((item) => item === null)) { + throw new Error( + "affected items must use canonical repository-relative paths", + ); + } + const sorted = (canonical as AffectedItem[]).sort((left, right) => + bytewiseCompare(affectedTuple(left), affectedTuple(right)), + ); + const serialized = sorted.map((item) => `${affectedTuple(item)}\n`).join(""); + return { + total: sorted.length, + sample_limit: AFFECTED_SAMPLE_LIMIT, + sample: sorted.slice(0, AFFECTED_SAMPLE_LIMIT), + truncated: sorted.length > AFFECTED_SAMPLE_LIMIT, + sha256: createHash("sha256").update(serialized).digest("hex"), + }; +} + +export function orchestrationPreviewResult(): RepositoryIndexResult { + return { + ...repositoryResult( + "preview_ready", + "blocked_until_version_proven", + "none", + { + gbrain_contacted: false, + compatibility_proven: false, + required_gbrain_version: REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION, + }, + "gbrain --version", + ), + preview_kind: "orchestration_unvalidated", + }; +} + +function parseJsonDocument(stdout: string): unknown | null { + try { + return JSON.parse(stdout); + } catch { + return null; + } +} + +interface ValidatedSyncEvidence { + fromCommit: string | null; + operations: { + added: number; + modified: number; + deleted: number; + renamed: number; + }; + affected: { + total: number; + sample_limit: number; + sample: AffectedItem[]; + truncated: boolean; + }; + affectedDigest: string; + corpus: { + markdown_planned_or_applied: number; + code_pages_before: number; + code_pages_after: number; + code_deletions_applied: number; + image_operations_applied: 0; + image_pages_after: number; + multimodal_enabled: false; + embedding_status: "deferred" | "complete"; + extraction_status: "deferred" | "complete"; + search_ready: boolean; + }; + planDigest: string; +} + +interface ValidatedChildResult extends ValidatedSyncEvidence { + status: "synced" | "first_sync" | "up_to_date"; + bookmarkAfter: string; +} + +interface ValidatedPreviewResult extends ValidatedSyncEvidence { + status: "dry_run"; + bookmarkAfter: string | null; +} + +type SyncVerdict = + | { kind: "complete"; value: T } + | { + kind: "incomplete"; + reason: "sync_partial" | "sync_blocked" | "sync_failed"; + state: RepositoryIndexState; + } + | { + kind: "child_error"; + reasonCode: string; + state: RepositoryIndexState; + status: "incomplete" | "refused" | "error"; + exitCode: 1 | 2; + problem: string | null; + nextAction: string | null; + observed: string | null; + required: string | null; + } + | { kind: "invalid"; detail: string }; + +type ChildVerdict = SyncVerdict; +type PreviewVerdict = SyncVerdict; + +function validateSyncResult( + raw: unknown, + expected: { + sourceId: string; + head: string; + bookmarkBefore: string | null; + lastSuccessfulStrategyBefore?: "markdown" | "code" | "auto" | null; + }, + phase: "preview" | "apply", +): ChildVerdict | PreviewVerdict { + if (!isRecord(raw) || raw.schema_version !== 1) { + return { kind: "invalid", detail: "missing schema_version 1" }; + } + if (raw.result_kind === "gbrain_sync_error") { + const childStates = new Set([ + "none", + "lock_only", + "partial", + ]); + if ( + (raw.status === "refused" || raw.status === "error") && + typeof raw.reason_code === "string" && + typeof raw.state_changed === "string" && + childStates.has(raw.state_changed as RepositoryIndexState) && + validChildErrorTuple(raw.reason_code, raw.status, raw.state_changed) && + (raw.reason_code !== "plan_changed" || + (typeof raw.observed === "string" && + SHA_256.test(raw.observed) && + typeof raw.required === "string" && + SHA_256.test(raw.required))) + ) { + const state = raw.state_changed as RepositoryIndexState; + return { + kind: "child_error", + reasonCode: raw.reason_code, + state, + status: + raw.reason_code === "lock_busy" + ? "incomplete" + : raw.reason_code === "lock_release_failed" + ? "error" + : raw.status, + exitCode: raw.reason_code === "lock_busy" ? 2 : 1, + problem: typeof raw.problem === "string" ? raw.problem : null, + nextAction: + typeof raw.next_action === "string" ? raw.next_action : null, + observed: typeof raw.observed === "string" ? raw.observed : null, + required: typeof raw.required === "string" ? raw.required : null, + }; + } + return { kind: "invalid", detail: "malformed gbrain_sync_error" }; + } + if (raw.result_kind !== "gbrain_sync" || typeof raw.status !== "string") { + return { kind: "invalid", detail: "wrong child result kind or status" }; + } + if (phase === "preview" && raw.status !== "dry_run") { + return { + kind: "invalid", + detail: `preview did not return dry_run: ${raw.status}`, + }; + } + if (phase === "preview" && raw.preview_kind !== "validated_index_plan") { + return { + kind: "invalid", + detail: "preview did not return validated_index_plan", + }; + } + if (phase === "apply" && CHILD_INCOMPLETE_STATUSES.has(raw.status)) { + return { + kind: "incomplete", + reason: + raw.status === "blocked_by_failures" ? "sync_blocked" : "sync_partial", + state: "partial", + }; + } + if (phase === "apply" && !CHILD_TERMINAL_STATUSES.has(raw.status)) { + return { kind: "invalid", detail: `unknown child status: ${raw.status}` }; + } + const expectedBookmarkAfter = + phase === "preview" ? expected.bookmarkBefore : expected.head; + const expectedLastSuccessfulStrategy = + phase === "preview" ? expected.lastSuccessfulStrategyBefore : "auto"; + if ( + !isRecord(raw.source) || + raw.source.id !== expected.sourceId || + !isRecord(raw.repository) || + raw.repository.from_commit !== expected.bookmarkBefore || + raw.repository.target_commit !== expected.head || + raw.repository.bookmark_after !== expectedBookmarkAfter || + raw.repository.last_successful_strategy !== + expectedLastSuccessfulStrategy || + raw.strategy !== "auto" + ) { + return { + kind: "invalid", + detail: "child source, commit, bookmark, or strategy evidence mismatched", + }; + } + if (!isRecord(raw.operations)) { + return { kind: "invalid", detail: "missing operation counts" }; + } + const operations = { + added: raw.operations.added, + modified: raw.operations.modified, + deleted: raw.operations.deleted, + renamed: raw.operations.renamed, + }; + if (!Object.values(operations).every(nonNegativeInteger)) { + return { kind: "invalid", detail: "invalid operation counts" }; + } + if ( + phase === "apply" && + raw.status === "up_to_date" && + Object.values(operations).some((count) => count !== 0) + ) { + return { + kind: "invalid", + detail: "up_to_date cannot report content mutations", + }; + } + if ( + !isRecord(raw.affected) || + !nonNegativeInteger(raw.affected.total) || + raw.affected.sample_limit !== AFFECTED_SAMPLE_LIMIT || + !Array.isArray(raw.affected.sample) || + typeof raw.affected.truncated !== "boolean" || + typeof raw.affected_digest !== "string" || + !SHA_256.test(raw.affected_digest) || + typeof raw.plan_digest !== "string" || + !SHA_256.test(raw.plan_digest) + ) { + return { + kind: "invalid", + detail: "invalid bounded affected evidence or plan_digest", + }; + } + const expectedTotal = + operations.added + + operations.modified + + operations.deleted + + operations.renamed; + if ( + raw.affected.total !== expectedTotal || + raw.affected.sample.length !== + Math.min(raw.affected.total, AFFECTED_SAMPLE_LIMIT) || + raw.affected.truncated !== raw.affected.total > AFFECTED_SAMPLE_LIMIT + ) { + return { + kind: "invalid", + detail: "affected totals or truncation do not match operation counts", + }; + } + const sample: AffectedItem[] = []; + for (const value of raw.affected.sample) { + if (!isRecord(value)) { + return { + kind: "invalid", + detail: "affected sample row is not an object", + }; + } + const operation = + typeof value.operation === "string" ? value.operation : ""; + const hasFromPath = Object.hasOwn(value, "from_path"); + if ( + (operation === "rename" && typeof value.from_path !== "string") || + (operation !== "rename" && hasFromPath) + ) { + return { + kind: "invalid", + detail: "affected from_path does not match the operation kind", + }; + } + const item = canonicalAffectedItem( + { + operation, + path: typeof value.path === "string" ? value.path : "", + slug: typeof value.slug === "string" ? value.slug : "", + ...(typeof value.from_path === "string" + ? { from_path: value.from_path } + : {}), + }, + true, + ); + if (!item) { + return { kind: "invalid", detail: "affected sample row is invalid" }; + } + sample.push(item); + } + const sortedSample = [...sample].sort((left, right) => + bytewiseCompare(affectedTuple(left), affectedTuple(right)), + ); + if ( + sample.some( + (item, index) => + affectedTuple(item) !== affectedTuple(sortedSample[index]), + ) + ) { + return { kind: "invalid", detail: "affected sample is not canonical" }; + } + if ( + raw.affected.truncated === false && + summarizeAffectedItems(sample).sha256 !== raw.affected_digest + ) { + return { + kind: "invalid", + detail: "affected digest does not match the complete sample", + }; + } + const sampleCounts = { + added: sample.filter((item) => item.operation === "add").length, + modified: sample.filter((item) => item.operation === "modify").length, + deleted: sample.filter((item) => item.operation === "delete").length, + renamed: sample.filter((item) => item.operation === "rename").length, + }; + const countKeys = Object.keys(sampleCounts) as Array< + keyof typeof sampleCounts + >; + if ( + countKeys.some((key) => + raw.affected.truncated + ? sampleCounts[key] > operations[key] + : sampleCounts[key] !== operations[key], + ) + ) { + return { + kind: "invalid", + detail: "affected sample kinds do not match operation counts", + }; + } + if (!validCorpusReceipt(raw.corpus)) { + return { + kind: "invalid", + detail: "corpus receipt values are incomplete or contradictory", + }; + } + + const evidence: ValidatedSyncEvidence = { + fromCommit: raw.repository.from_commit as string | null, + operations: operations as ValidatedSyncEvidence["operations"], + affected: { + total: raw.affected.total, + sample_limit: raw.affected.sample_limit, + sample, + truncated: raw.affected.truncated, + }, + affectedDigest: raw.affected_digest, + corpus: raw.corpus as unknown as ValidatedSyncEvidence["corpus"], + planDigest: raw.plan_digest, + }; + if (phase === "preview") { + return { + kind: "complete", + value: { + ...evidence, + status: "dry_run", + bookmarkAfter: raw.repository.bookmark_after as string | null, + }, + }; + } + return { + kind: "complete", + value: { + ...evidence, + status: raw.status as ValidatedChildResult["status"], + bookmarkAfter: raw.repository.bookmark_after as string, + }, + }; +} + +function validateChildResult( + raw: unknown, + expected: { + sourceId: string; + head: string; + bookmarkBefore: string | null; + }, +): ChildVerdict { + return validateSyncResult(raw, expected, "apply") as ChildVerdict; +} + +function validatePreviewResult( + raw: unknown, + expected: { + sourceId: string; + head: string; + bookmarkBefore: string | null; + lastSuccessfulStrategyBefore: "markdown" | "code" | "auto" | null; + }, +): PreviewVerdict { + return validateSyncResult(raw, expected, "preview") as PreviewVerdict; +} + +export function writeRepositoryIndexReceipt( + path: string, + receipt: RepositoryIndexResult, +): void { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.tmp.${process.pid}`; + try { + writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`, { + encoding: "utf-8", + flag: "wx", + mode: 0o600, + }); + renameSync(temporary, path); + } catch (error) { + try { + unlinkSync(temporary); + } catch { + // Best effort: the original receipt was never replaced. + } + throw error; + } +} + +export function writeRepositoryIndexAttemptMarker( + path: string, + marker: RepositoryIndexAttemptMarker, +): void { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.tmp.${process.pid}.${randomBytes(8).toString("hex")}`; + try { + writeFileSync(temporary, `${JSON.stringify(marker, null, 2)}\n`, { + encoding: "utf-8", + flag: "wx", + mode: 0o600, + }); + renameSync(temporary, path); + } catch (error) { + try { + unlinkSync(temporary); + } catch { + // Best effort: the active marker was never replaced. + } + throw error; + } +} + +export function clearRepositoryIndexAttemptMarker(path: string): void { + unlinkSync(path); +} + +function repositoryIndexActivity(gstackHome: string): + | { active: false } + | { + active: true; + kind: "wrapper_lock" | "attempt_watermark"; + path: string; + detail?: string; + } { + for (const [kind, path] of [ + ["wrapper_lock", join(gstackHome, REPOSITORY_INDEX_LOCK)], + ["attempt_watermark", join(gstackHome, REPOSITORY_INDEX_ATTEMPT)], + ] as const) { + try { + lstatSync(path); + return { active: true, kind, path }; + } catch (error) { + const missing = + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT"; + if (!missing) { + return { + active: true, + kind, + path, + detail: error instanceof Error ? error.message : String(error), + }; + } + } + } + return { active: false }; +} + +function readCurrentRepositoryState(root: string): RepositoryState { + const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0" }; + const head = spawnSync("git", ["rev-parse", "--verify", "HEAD"], { + cwd: root, + encoding: "utf-8", + timeout: 5_000, + stdio: ["ignore", "pipe", "pipe"], + env, + }); + const status = spawnSync( + "git", + ["status", "--porcelain=v1", "--untracked-files=all"], + { + cwd: root, + encoding: "utf-8", + timeout: 5_000, + stdio: ["ignore", "pipe", "pipe"], + env, + }, + ); + const trackedMarker = spawnSync("git", ["ls-files", "--", ".gbrain-source"], { + cwd: root, + encoding: "utf-8", + timeout: 5_000, + stdio: ["ignore", "pipe", "pipe"], + env, + }); + const headAfter = spawnSync("git", ["rev-parse", "--verify", "HEAD"], { + cwd: root, + encoding: "utf-8", + timeout: 5_000, + stdio: ["ignore", "pipe", "pipe"], + env, + }); + const porcelain = status.status === 0 ? (status.stdout || "").trim() : null; + const headText = head.status === 0 ? (head.stdout || "").trim() : null; + const headAfterText = + headAfter.status === 0 ? (headAfter.stdout || "").trim() : null; + return { + head: headText, + head_after: headAfterText, + stable: headText !== null && headText === headAfterText, + clean: porcelain === "", + porcelain, + source_marker_tracked: + trackedMarker.status === 0 + ? (trackedMarker.stdout || "").trim() !== "" + : null, + }; +} + +function readCurrentAttachedSource(root: string): AttachedSourceState { + const path = join(root, ".gbrain-source"); + let descriptor: number | null = null; + try { + descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + const stat = fstatSync(descriptor); + if (!stat.isFile()) { + return { + present: true, + sourceId: null, + trustworthy: false, + detail: ".gbrain-source is not a regular file", + }; + } + if ( + typeof process.getuid === "function" && + stat.uid !== process.getuid() && + stat.uid !== 0 + ) { + return { + present: true, + sourceId: null, + trustworthy: false, + detail: ".gbrain-source is not owned by the current user or root", + }; + } + if (typeof process.getuid === "function" && (stat.mode & 0o022) !== 0) { + return { + present: true, + sourceId: null, + trustworthy: false, + detail: ".gbrain-source is group- or world-writable", + }; + } + if (stat.size > SOURCE_MARKER_MAX_BYTES) { + return { + present: true, + sourceId: null, + trustworthy: false, + detail: `.gbrain-source exceeds ${SOURCE_MARKER_MAX_BYTES} bytes`, + }; + } + const marker = readFileSync(descriptor, "utf-8"); + const sourceId = marker.endsWith("\r\n") + ? marker.slice(0, -2) + : marker.endsWith("\n") + ? marker.slice(0, -1) + : marker; + if (!SOURCE_ID.test(sourceId)) { + return { + present: true, + sourceId: null, + trustworthy: false, + detail: ".gbrain-source does not contain one canonical source id", + }; + } + return { + present: true, + sourceId, + trustworthy: true, + detail: null, + }; + } catch (error) { + const missing = + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT"; + return { + present: !missing, + sourceId: null, + trustworthy: false, + detail: error instanceof Error ? error.message : String(error), + }; + } finally { + if (descriptor !== null) closeSync(descriptor); + } +} + +/** + * Replace `.gbrain-source` without ever opening the destination for writing. + * + * GBrain's historical `sources attach` implementation used a truncating + * write, so a symlink introduced after the wrapper's preflight could redirect + * that write outside the repository. A same-directory exclusive temporary + * file plus rename replaces the directory entry itself (including a symlink) + * and never follows its target. + */ +export function writeRepositorySourceMarker( + root: string, + sourceId: string, +): void { + if (!SOURCE_ID.test(sourceId)) { + throw new Error("source id is not canonical"); + } + const canonicalRoot = realpathSync.native(root); + if (!statSync(canonicalRoot).isDirectory()) { + throw new Error("repository root is not a directory"); + } + + const markerPath = join(canonicalRoot, ".gbrain-source"); + const temporaryPath = join( + canonicalRoot, + `.gbrain-source.tmp.${process.pid}.${randomBytes(12).toString("hex")}`, + ); + let descriptor: number | null = null; + try { + descriptor = openSync( + temporaryPath, + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_EXCL | + fsConstants.O_NOFOLLOW, + 0o600, + ); + const temporaryStat = fstatSync(descriptor); + if (!temporaryStat.isFile()) { + throw new Error("temporary source marker is not a regular file"); + } + writeFileSync(descriptor, `${sourceId}\n`, "utf-8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = null; + renameSync(temporaryPath, markerPath); + } catch (error) { + if (descriptor !== null) { + try { + closeSync(descriptor); + } catch { + // Preserve the original failure. + } + } + try { + unlinkSync(temporaryPath); + } catch { + // Best effort: an exclusive temporary file is never the trusted marker. + } + throw error; + } +} + +function ensureLocalSourceMarkerExclude( + root: string, +): + { ok: true; path: string; changed: boolean } | { ok: false; detail: string } { + const commonDirProbe = spawnSync("git", ["rev-parse", "--git-common-dir"], { + cwd: root, + encoding: "utf-8", + timeout: 5_000, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + }); + if (commonDirProbe.status !== 0) { + return { + ok: false, + detail: ( + commonDirProbe.stderr || "git common directory unavailable" + ).trim(), + }; + } + + try { + const rawCommonDir = (commonDirProbe.stdout || "").trim(); + if (!rawCommonDir) { + return { ok: false, detail: "git common directory was empty" }; + } + const commonDir = realpathSync.native( + isAbsolute(rawCommonDir) ? rawCommonDir : resolve(root, rawCommonDir), + ); + const infoDir = join(commonDir, "info"); + mkdirSync(infoDir, { recursive: true }); + if (realpathSync.native(infoDir) !== infoDir) { + return { + ok: false, + detail: "git info directory did not preserve canonical identity", + }; + } + const excludePath = join(infoDir, "exclude"); + let excludeDescriptor: number | null = null; + try { + try { + excludeDescriptor = openSync( + excludePath, + fsConstants.O_RDWR | fsConstants.O_APPEND | fsConstants.O_NOFOLLOW, + ); + } catch (error) { + if (!( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + )) { + throw error; + } + excludeDescriptor = openSync( + excludePath, + fsConstants.O_RDWR | + fsConstants.O_APPEND | + fsConstants.O_CREAT | + fsConstants.O_EXCL | + fsConstants.O_NOFOLLOW, + 0o600, + ); + } + const stat = fstatSync(excludeDescriptor); + if (!stat.isFile()) { + return { + ok: false, + detail: "git info/exclude is not a regular file", + }; + } + if ( + typeof process.getuid === "function" && + stat.uid !== process.getuid() && + stat.uid !== 0 + ) { + return { + ok: false, + detail: "git info/exclude is not owned by the current user or root", + }; + } + if (typeof process.getuid === "function" && (stat.mode & 0o022) !== 0) { + return { + ok: false, + detail: "git info/exclude is group- or world-writable", + }; + } + const existing = readFileSync(excludeDescriptor, "utf-8"); + if ( + existing.split("\n").some((line) => line.trim() === ".gbrain-source") + ) { + return { ok: true, path: excludePath, changed: false }; + } + appendFileSync( + excludeDescriptor, + `${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}.gbrain-source\n`, + "utf-8", + ); + return { ok: true, path: excludePath, changed: true }; + } finally { + if (excludeDescriptor !== null) closeSync(excludeDescriptor); + } + } catch (error) { + return { + ok: false, + detail: error instanceof Error ? error.message : String(error), + }; + } +} + +function receiptVerificationFailure( + reasonCode: + | "receipt_missing" + | "receipt_invalid" + | "receipt_stale" + | "receipt_in_progress" + | "receipt_superseded", + evidence: Record, +): RepositoryIndexRunOutput { + return { + result: repositoryResult( + reasonCode === "receipt_invalid" ? "error" : "refused", + reasonCode, + "none", + evidence, + "/sync-gbrain --code-only", + ), + exitCode: 1, + }; +} + +function readRepositoryIndexReceipt(path: string): unknown { + let descriptor: number | null = null; + try { + descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + const stat = fstatSync(descriptor); + if (!stat.isFile()) { + throw new Error("receipt is not a regular file"); + } + if ( + typeof process.getuid === "function" && + stat.uid !== process.getuid() && + stat.uid !== 0 + ) { + throw new Error("receipt is not owned by the current user or root"); + } + if (typeof process.getuid === "function" && (stat.mode & 0o022) !== 0) { + throw new Error("receipt is group- or world-writable"); + } + if (stat.size > 1024 * 1024) { + throw new Error("receipt exceeds 1 MiB"); + } + return JSON.parse(readFileSync(descriptor, "utf-8")); + } finally { + if (descriptor !== null) closeSync(descriptor); + } +} + +/** + * Rebind the persisted GREEN receipt to the repository that is live now. + * + * The receipt path is global to GSTACK_HOME. Consumers must prove that no + * wrapper lock or attempt watermark is active, then bind the receipt to this + * canonical root, source marker, and current clean full HEAD. The activity + * check is repeated after the live Git reads so a concurrent attempt cannot + * race historical GREEN evidence back into use. + */ +export function verifyCurrentRepositoryIndexReceipt( + root: string, + gstackHome: string, +): RepositoryIndexRunOutput { + const receiptPath = join(gstackHome, REPOSITORY_INDEX_RECEIPT); + const activityBefore = repositoryIndexActivity(gstackHome); + if (activityBefore.active) { + return receiptVerificationFailure( + activityBefore.kind === "wrapper_lock" + ? "receipt_in_progress" + : "receipt_superseded", + { + receipt_path: receiptPath, + active_kind: activityBefore.kind, + active_path: activityBefore.path, + ...(activityBefore.detail ? { detail: activityBefore.detail } : {}), + }, + ); + } + let raw: unknown; + try { + raw = readRepositoryIndexReceipt(receiptPath); + } catch (error) { + const missing = + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT"; + return receiptVerificationFailure( + missing ? "receipt_missing" : "receipt_invalid", + { + receipt_path: receiptPath, + detail: error instanceof Error ? error.message : String(error), + }, + ); + } + + if ( + !isRecord(raw) || + raw.schema_version !== 1 || + raw.result_kind !== "repository_index" || + raw.status !== "verified" || + (raw.reason_code !== "verified" && raw.reason_code !== "up_to_date") || + raw.state_changed !== "applied_verified" || + raw.next_command !== null || + raw.docs !== REPOSITORY_INDEX_RECOVERY_DOC || + !isRecord(raw.evidence) + ) { + return receiptVerificationFailure("receipt_invalid", { + receipt_path: receiptPath, + detail: "receipt envelope is not a schema-1 verified result", + }); + } + + const evidence = raw.evidence; + const source = evidence.source; + const repository = evidence.repository; + const sync = evidence.sync; + const verification = evidence.verification; + const corpus = evidence.corpus; + if ( + typeof evidence.gbrain_version !== "string" || + !isReleasedVersionAtLeast(evidence.gbrain_version) || + evidence.required_gbrain_version !== + REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION || + !isRecord(source) || + typeof source.id !== "string" || + !SOURCE_ID.test(source.id) || + typeof source.canonical_path !== "string" || + source.path_identity !== "equivalent" || + !isRecord(repository) || + typeof repository.git_head !== "string" || + !SHA_40.test(repository.git_head) || + repository.target_commit !== repository.git_head || + repository.bookmark_after !== repository.git_head || + repository.last_successful_strategy !== "auto" || + repository.working_tree_clean !== true || + !isRecord(sync) || + !CHILD_TERMINAL_STATUSES.has(String(sync.terminal_status)) || + sync.strategy !== "auto" || + typeof sync.plan_digest !== "string" || + !SHA_256.test(sync.plan_digest) || + !nonNegativeInteger(sync.added) || + !nonNegativeInteger(sync.modified) || + !nonNegativeInteger(sync.deleted) || + !nonNegativeInteger(sync.renamed) || + !isRecord(sync.affected) || + !nonNegativeInteger(sync.affected.total) || + sync.affected.sample_limit !== AFFECTED_SAMPLE_LIMIT || + !Array.isArray(sync.affected.sample) || + typeof sync.affected.truncated !== "boolean" || + typeof sync.affected.sha256 !== "string" || + !SHA_256.test(sync.affected.sha256) || + !validCorpusReceipt(corpus) || + !isRecord(verification) || + verification.source_path_matches !== true || + verification.bookmark_matches_clean_head !== true || + verification.attached_source_matches !== true || + verification.trusted !== true + ) { + return receiptVerificationFailure("receipt_invalid", { + receipt_path: receiptPath, + detail: "receipt evidence is incomplete or contradictory", + }); + } + + const operationTotal = + (sync.added as number) + + (sync.modified as number) + + (sync.deleted as number) + + (sync.renamed as number); + if ( + sync.affected.total !== operationTotal || + sync.affected.sample.length !== + Math.min(operationTotal, AFFECTED_SAMPLE_LIMIT) || + sync.affected.truncated !== operationTotal > AFFECTED_SAMPLE_LIMIT + ) { + return receiptVerificationFailure("receipt_invalid", { + receipt_path: receiptPath, + detail: "receipt affected summary does not match operation counts", + }); + } + + const receiptSample: AffectedItem[] = []; + for (const row of sync.affected.sample) { + if (!isRecord(row)) { + return receiptVerificationFailure("receipt_invalid", { + receipt_path: receiptPath, + detail: "receipt affected sample contains a non-object row", + }); + } + const item = canonicalAffectedItem( + { + operation: typeof row.operation === "string" ? row.operation : "", + path: typeof row.path === "string" ? row.path : "", + slug: typeof row.slug === "string" ? row.slug : "", + ...(typeof row.from_path === "string" + ? { from_path: row.from_path } + : {}), + }, + true, + ); + if ( + !item || + (item.operation === "rename") !== Object.hasOwn(row, "from_path") + ) { + return receiptVerificationFailure("receipt_invalid", { + receipt_path: receiptPath, + detail: "receipt affected sample row is invalid", + }); + } + receiptSample.push(item); + } + const canonicalSample = [...receiptSample].sort((left, right) => + bytewiseCompare(affectedTuple(left), affectedTuple(right)), + ); + if ( + receiptSample.some( + (item, index) => + affectedTuple(item) !== affectedTuple(canonicalSample[index]), + ) || + (!sync.affected.truncated && + summarizeAffectedItems(receiptSample).sha256 !== sync.affected.sha256) + ) { + return receiptVerificationFailure("receipt_invalid", { + receipt_path: receiptPath, + detail: "receipt affected sample or digest is not canonical", + }); + } + + let canonicalRoot: string; + try { + canonicalRoot = realpathSync.native(root); + } catch (error) { + return receiptVerificationFailure("receipt_stale", { + receipt_path: receiptPath, + detail: error instanceof Error ? error.message : String(error), + }); + } + const liveRepository = readCurrentRepositoryState(canonicalRoot); + const attachedSource = readCurrentAttachedSource(canonicalRoot); + if ( + source.canonical_path !== canonicalRoot || + liveRepository.head !== repository.git_head || + liveRepository.head_after !== repository.git_head || + !liveRepository.stable || + !liveRepository.clean || + liveRepository.source_marker_tracked !== false || + !attachedSource.trustworthy || + attachedSource.sourceId !== source.id + ) { + return receiptVerificationFailure("receipt_stale", { + receipt_path: receiptPath, + receipt_source_id: source.id, + receipt_canonical_path: source.canonical_path, + receipt_git_head: repository.git_head, + live_canonical_path: canonicalRoot, + live_repository: liveRepository, + attached_source: attachedSource, + }); + } + + const activityAfter = repositoryIndexActivity(gstackHome); + if (activityAfter.active) { + return receiptVerificationFailure( + activityAfter.kind === "wrapper_lock" + ? "receipt_in_progress" + : "receipt_superseded", + { + receipt_path: receiptPath, + active_kind: activityAfter.kind, + active_path: activityAfter.path, + ...(activityAfter.detail ? { detail: activityAfter.detail } : {}), + detail: + activityAfter.detail ?? + "repository-index activity began during receipt verification", + }, + ); + } + + return { + result: raw as unknown as RepositoryIndexResult, + exitCode: 0, + }; +} + +function childErrorOutput( + child: Extract, + sourceId: string, + head: string, +): RepositoryIndexRunOutput { + return { + result: repositoryResult( + child.status, + child.reasonCode, + child.state, + { + source_id: sourceId, + git_head: head, + child_problem: child.problem, + child_observed: child.observed, + child_required: child.required, + }, + child.nextAction, + ), + exitCode: child.exitCode, + }; +} + +/** + * Execute the repository-index transaction after the wrapper lifecycle lock is + * held and Git root/HEAD/cleanliness are captured. + */ +export function runRepositoryIndex( + input: RepositoryIndexRunInput, +): RepositoryIndexRunOutput { + const gbrainEnv: NodeJS.ProcessEnv = { + ...(input.baseEnv ?? process.env), + GBRAIN_EMBEDDING_MULTIMODAL: "false", + }; + const requiredVersion = parseReleasedGbrainVersion( + REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION, + ); + if (!requiredVersion) { + return { + result: repositoryResult("error", "unsupported_version", "none", { + required_gbrain_version: REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION, + }), + exitCode: 1, + }; + } + if (!SHA_40.test(input.head)) { + return { + result: repositoryResult("refused", "repository_state_invalid", "none", { + git_head: input.head, + }), + exitCode: 1, + }; + } + if ( + unsafeRepositoryPathForShell(input.root, input.platform ?? process.platform) + ) { + return { + result: repositoryResult("refused", "unsupported_path", "none", { + repository_root: input.root, + }), + exitCode: 1, + }; + } + + const receiptPath = join(input.gstackHome, REPOSITORY_INDEX_RECEIPT); + const attemptPath = join(input.gstackHome, REPOSITORY_INDEX_ATTEMPT); + const attemptMarker: RepositoryIndexAttemptMarker = { + schema_version: 1, + result_kind: "repository_index_attempt", + status: "in_progress", + source_id: input.sourceId, + repository_root: input.root, + git_head: input.head, + started_at: new Date().toISOString(), + }; + try { + (input.writeAttemptMarker ?? writeRepositoryIndexAttemptMarker)( + attemptPath, + attemptMarker, + ); + } catch (error) { + return { + result: repositoryResult("error", "verification_failed", "none", { + failing_step: "attempt_watermark_write", + source_id: input.sourceId, + git_head: input.head, + attempt_path: attemptPath, + detail: error instanceof Error ? error.message : String(error), + }), + exitCode: 1, + }; + } + try { + unlinkSync(receiptPath); + } catch (error) { + const missing = + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT"; + if (!missing) { + return { + result: repositoryResult("error", "verification_failed", "none", { + failing_step: "prior_receipt_invalidation", + source_id: input.sourceId, + git_head: input.head, + receipt_path: receiptPath, + attempt_path: attemptPath, + detail: error instanceof Error ? error.message : String(error), + }), + exitCode: 1, + }; + } + } + + const versionProbe = input.spawnGbrain(["--version"], { + timeout: 10_000, + baseEnv: gbrainEnv, + }); + const detectedText = (versionProbe.stdout || "").trim(); + const detectedVersion = + versionProbe.status === 0 ? parseReleasedGbrainVersion(detectedText) : null; + if ( + !detectedVersion || + compareReleasedVersions(detectedVersion, requiredVersion) < 0 + ) { + return { + result: repositoryResult( + "refused", + "unsupported_version", + "none", + { + detected_gbrain_version: detectedText || null, + required_gbrain_version: REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION, + }, + "gbrain --version", + ), + exitCode: 1, + }; + } + + const sourceProbe = input.spawnGbrain(["sources", "list", "--json"], { + timeout: 30_000, + baseEnv: gbrainEnv, + }); + const sourceRaw = + sourceProbe.status === 0 + ? parseJsonDocument(sourceProbe.stdout || "") + : null; + const sourceSnapshot = + sourceRaw === null ? null : parseStrictSourceSnapshot(sourceRaw); + if (!sourceSnapshot || !sourceSnapshot.ok) { + return { + result: repositoryResult( + "error", + "source_probe_failed", + "none", + { + detail: + sourceSnapshot && !sourceSnapshot.ok + ? sourceSnapshot.error + : ( + sourceProbe.stderr || + "sources list did not return one JSON document" + ).trim(), + }, + "gbrain sources list --json", + ), + exitCode: 1, + }; + } + + const source = sourceSnapshot.rows.find((row) => row.id === input.sourceId); + if (!source) { + let canonicalRoot: string; + try { + canonicalRoot = realpathSync.native(input.root); + } catch (error) { + return { + result: repositoryResult("refused", "source_path_ambiguous", "none", { + repository_root: input.root, + detail: error instanceof Error ? error.message : String(error), + }), + exitCode: 1, + }; + } + if ( + unsafeRepositoryPathForShell( + canonicalRoot, + input.platform ?? process.platform, + ) + ) { + return { + result: repositoryResult("refused", "unsupported_path", "none", { + repository_root: input.root, + canonical_path: canonicalRoot, + }), + exitCode: 1, + }; + } + const registrationRepositoryState = + readCurrentRepositoryState(canonicalRoot); + if ( + registrationRepositoryState.head !== input.head || + registrationRepositoryState.head_after !== input.head || + !registrationRepositoryState.stable || + registrationRepositoryState.source_marker_tracked !== false + ) { + return { + result: repositoryResult( + "refused", + "repository_state_invalid", + "none", + { + failing_step: "source_registration_repository_state", + expected_head: input.head, + repository_state: registrationRepositoryState, + }, + ), + exitCode: 1, + }; + } + const equivalentOwners = equivalentSourceOwnerIds( + sourceSnapshot.rows, + canonicalRoot, + input.platform ?? process.platform, + ); + if (equivalentOwners.length > 0) { + return { + result: repositoryResult( + "refused", + "source_path_ambiguous", + "none", + { + source_id: input.sourceId, + canonical_path: canonicalRoot, + existing_source_ids: equivalentOwners, + }, + "gbrain sources list --json", + ), + exitCode: 1, + }; + } + const registration = input.spawnGbrain( + [ + "sources", + "add", + input.sourceId, + "--path", + canonicalRoot, + "--federated", + ], + { cwd: canonicalRoot, timeout: 30_000, baseEnv: gbrainEnv }, + ); + if (registration.status !== 0) { + return { + result: repositoryResult( + "error", + "source_registration_failed", + "partial", + { + source_id: input.sourceId, + repository_root: input.root, + detail: (registration.stderr || registration.stdout || "").trim(), + }, + ), + exitCode: 1, + }; + } + return { + result: repositoryResult( + "incomplete", + "source_registered", + "registry_only", + { + source_id: input.sourceId, + canonical_path: canonicalRoot, + required_gbrain_version: REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION, + }, + "/sync-gbrain --code-only", + ), + exitCode: 2, + }; + } + + if (source.local_path === null) { + return { + result: repositoryResult( + "refused", + "source_path_ambiguous", + "none", + { + source_id: input.sourceId, + stored_path: null, + detail: "the matching source has no local_path", + }, + "gbrain sources list --json", + ), + exitCode: 1, + }; + } + + const pathIdentity = classifyRepositoryPath( + source.local_path, + input.root, + input.platform ?? process.platform, + ); + if (pathIdentity.kind !== "equivalent") { + return { + result: repositoryResult( + "refused", + pathIdentity.kind === "different" + ? "source_path_different" + : "source_path_ambiguous", + "none", + { + source_id: input.sourceId, + path_identity: pathIdentity, + }, + "gbrain sources list --json", + ), + exitCode: 1, + }; + } + if ( + unsafeRepositoryPathForShell( + pathIdentity.canonical_path, + input.platform ?? process.platform, + ) + ) { + return { + result: repositoryResult("refused", "unsupported_path", "none", { + repository_root: input.root, + canonical_path: pathIdentity.canonical_path, + }), + exitCode: 1, + }; + } + const equivalentOwners = equivalentSourceOwnerIds( + sourceSnapshot.rows, + pathIdentity.canonical_path, + input.platform ?? process.platform, + ); + if (equivalentOwners.length !== 1 || equivalentOwners[0] !== input.sourceId) { + return { + result: repositoryResult( + "refused", + "source_path_ambiguous", + "none", + { + source_id: input.sourceId, + canonical_path: pathIdentity.canonical_path, + existing_source_ids: equivalentOwners, + }, + "gbrain sources list --json", + ), + exitCode: 1, + }; + } + + const attachedBeforeSync = readCurrentAttachedSource( + pathIdentity.canonical_path, + ); + let localExcludeBeforeSync: + { ok: true; path: string; changed: boolean } | undefined; + const stateAfterPreSyncMetadata = ( + state: RepositoryIndexState, + ): RepositoryIndexState => + state === "none" && localExcludeBeforeSync?.changed === true + ? "partial" + : state; + if (attachedBeforeSync.present) { + if ( + !attachedBeforeSync.trustworthy || + attachedBeforeSync.sourceId !== input.sourceId + ) { + return { + result: repositoryResult("refused", "verification_failed", "none", { + failing_step: "pre_sync_source_marker", + source_id: input.sourceId, + attached_source: attachedBeforeSync, + }), + exitCode: 1, + }; + } + if (!input.workingTreeClean) { + const exclude = ensureLocalSourceMarkerExclude( + pathIdentity.canonical_path, + ); + if (!exclude.ok) { + return { + result: repositoryResult("error", "verification_failed", "none", { + failing_step: "source_marker_exclude", + source_id: input.sourceId, + detail: exclude.detail, + }), + exitCode: 1, + }; + } + localExcludeBeforeSync = exclude; + } + } + const preRepositoryState = readCurrentRepositoryState( + pathIdentity.canonical_path, + ); + if ( + preRepositoryState.head !== input.head || + preRepositoryState.head_after !== input.head || + !preRepositoryState.stable || + preRepositoryState.source_marker_tracked !== false + ) { + return { + result: repositoryResult( + "refused", + "repository_state_invalid", + stateAfterPreSyncMetadata("none"), + { + failing_step: "pre_sync_repository_state", + expected_head: input.head, + repository_state: preRepositoryState, + }, + ), + exitCode: 1, + }; + } + const workingTreeCleanBeforeSync = preRepositoryState.clean; + + const expectedBookmark = source.last_commit ?? "none"; + const expectedStateArgs = [ + "sync", + "--strategy", + "auto", + "--source", + input.sourceId, + "--repo", + pathIdentity.canonical_path, + "--no-pull", + "--expected-target", + input.head, + "--expected-bookmark", + expectedBookmark, + ]; + if (workingTreeCleanBeforeSync) { + expectedStateArgs.push("--require-clean"); + } + + const previewArgs = [...expectedStateArgs, "--dry-run", "--json"]; + const preview = input.spawnGbrain(previewArgs, { + cwd: pathIdentity.canonical_path, + timeout: 35 * 60 * 1000, + baseEnv: gbrainEnv, + }); + const previewRaw = parseJsonDocument(preview.stdout || ""); + if (preview.status !== 0) { + if (previewRaw !== null) { + const refused = validatePreviewResult(previewRaw, { + sourceId: input.sourceId, + head: input.head, + bookmarkBefore: source.last_commit, + lastSuccessfulStrategyBefore: source.last_successful_strategy, + }); + if (refused.kind === "child_error") { + return childErrorOutput( + { + ...refused, + state: stateAfterPreSyncMetadata(refused.state), + }, + input.sourceId, + input.head, + ); + } + if (refused.kind === "invalid") { + return { + result: repositoryResult( + "error", + "source_result_invalid", + stateAfterPreSyncMetadata("none"), + { + failing_step: "sync_preview", + source_id: input.sourceId, + git_head: input.head, + child_exit: preview.status, + detail: refused.detail, + }, + ), + exitCode: 1, + }; + } + } + return { + result: repositoryResult( + "error", + "sync_failed", + stateAfterPreSyncMetadata("none"), + { + failing_step: "sync_preview", + source_id: input.sourceId, + git_head: input.head, + child_exit: preview.status, + child_stderr: (preview.stderr || "").trim(), + }, + ), + exitCode: 1, + }; + } + if (previewRaw === null) { + return { + result: repositoryResult( + "error", + "source_result_invalid", + stateAfterPreSyncMetadata("none"), + { + failing_step: "sync_preview", + source_id: input.sourceId, + detail: "preview stdout was not exactly one JSON document", + }, + ), + exitCode: 1, + }; + } + const previewResult = validatePreviewResult(previewRaw, { + sourceId: input.sourceId, + head: input.head, + bookmarkBefore: source.last_commit, + lastSuccessfulStrategyBefore: source.last_successful_strategy, + }); + if (previewResult.kind !== "complete") { + if (previewResult.kind === "child_error") { + return childErrorOutput( + { + ...previewResult, + state: stateAfterPreSyncMetadata(previewResult.state), + }, + input.sourceId, + input.head, + ); + } + return { + result: repositoryResult( + "error", + "source_result_invalid", + stateAfterPreSyncMetadata("none"), + { + failing_step: "sync_preview", + source_id: input.sourceId, + git_head: input.head, + detail: + previewResult.kind === "invalid" + ? previewResult.detail + : `preview returned ${previewResult.reason}`, + }, + ), + exitCode: 1, + }; + } + + const syncArgs = [ + ...expectedStateArgs, + "--expected-plan-digest", + previewResult.value.planDigest, + "--json", + ]; + + const sync = input.spawnGbrain(syncArgs, { + cwd: pathIdentity.canonical_path, + timeout: 35 * 60 * 1000, + baseEnv: gbrainEnv, + }); + const childRaw = parseJsonDocument(sync.stdout || ""); + if (sync.status !== 0) { + if (childRaw !== null) { + const refused = validateChildResult(childRaw, { + sourceId: input.sourceId, + head: input.head, + bookmarkBefore: source.last_commit, + }); + if (refused.kind === "incomplete") { + return { + result: repositoryResult( + "incomplete", + refused.reason, + refused.state, + { + source_id: input.sourceId, + git_head: input.head, + child_exit: sync.status, + }, + ), + exitCode: refused.reason === "sync_blocked" ? 2 : 1, + }; + } + if (refused.kind === "child_error") { + return childErrorOutput( + { + ...refused, + state: stateAfterPreSyncMetadata(refused.state), + }, + input.sourceId, + input.head, + ); + } + if (refused.kind === "invalid") { + return { + result: repositoryResult( + "error", + "source_result_invalid", + "partial", + { + source_id: input.sourceId, + git_head: input.head, + child_exit: sync.status, + detail: refused.detail, + }, + ), + exitCode: 1, + }; + } + } + return { + result: repositoryResult("error", "sync_failed", "partial", { + source_id: input.sourceId, + git_head: input.head, + child_exit: sync.status, + child_stderr: (sync.stderr || "").trim(), + }), + exitCode: 1, + }; + } + if (childRaw === null) { + return { + result: repositoryResult( + "error", + "source_result_invalid", + "applied_unverified", + { + source_id: input.sourceId, + detail: "child stdout was not exactly one JSON document", + }, + ), + exitCode: 1, + }; + } + const child = validateChildResult(childRaw, { + sourceId: input.sourceId, + head: input.head, + bookmarkBefore: source.last_commit, + }); + if (child.kind === "incomplete") { + return { + result: repositoryResult("incomplete", child.reason, child.state, { + source_id: input.sourceId, + git_head: input.head, + }), + exitCode: 1, + }; + } + if (child.kind === "child_error") { + return childErrorOutput( + { + ...child, + state: stateAfterPreSyncMetadata(child.state), + }, + input.sourceId, + input.head, + ); + } + if (child.kind === "invalid") { + return { + result: repositoryResult( + "error", + "source_result_invalid", + "applied_unverified", + { + source_id: input.sourceId, + detail: child.detail, + }, + ), + exitCode: 1, + }; + } + if (child.value.planDigest !== previewResult.value.planDigest) { + return { + result: repositoryResult( + "error", + "source_result_invalid", + "applied_unverified", + { + failing_step: "sync_plan_binding", + source_id: input.sourceId, + git_head: input.head, + preview_plan_digest: previewResult.value.planDigest, + apply_plan_digest: child.value.planDigest, + detail: + "apply result plan_digest did not match the validated preview", + }, + ), + exitCode: 1, + }; + } + + const postSourceProbe = input.spawnGbrain(["sources", "list", "--json"], { + timeout: 30_000, + baseEnv: gbrainEnv, + }); + const postSourceRaw = + postSourceProbe.status === 0 + ? parseJsonDocument(postSourceProbe.stdout || "") + : null; + const postSnapshot = + postSourceRaw === null ? null : parseStrictSourceSnapshot(postSourceRaw); + const postSource = + postSnapshot?.ok === true + ? postSnapshot.rows.find((row) => row.id === input.sourceId) + : undefined; + const postEquivalentOwners = + postSnapshot?.ok === true + ? equivalentSourceOwnerIds( + postSnapshot.rows, + input.root, + input.platform ?? process.platform, + ) + : []; + const postPathIdentity = postSource?.local_path + ? classifyRepositoryPath( + postSource.local_path, + input.root, + input.platform ?? process.platform, + ) + : null; + if ( + !postSource || + postEquivalentOwners.length !== 1 || + postEquivalentOwners[0] !== input.sourceId || + postPathIdentity?.kind !== "equivalent" || + postSource.last_commit !== input.head || + postSource.last_successful_strategy !== "auto" + ) { + return { + result: repositoryResult( + "error", + "verification_failed", + "applied_unverified", + { + failing_step: "post_sync_source_snapshot", + source_id: input.sourceId, + existing_source_ids: postEquivalentOwners, + git_head: input.head, + detail: + postSnapshot && !postSnapshot.ok + ? postSnapshot.error + : (postSourceProbe.stderr || "").trim(), + }, + ), + exitCode: 1, + }; + } + + try { + (input.writeSourceMarker ?? writeRepositorySourceMarker)( + pathIdentity.canonical_path, + input.sourceId, + ); + } catch (error) { + return { + result: repositoryResult( + "error", + "verification_failed", + "applied_unverified", + { + failing_step: "source_attach", + source_id: input.sourceId, + git_head: input.head, + detail: error instanceof Error ? error.message : String(error), + }, + ), + exitCode: 1, + }; + } + const localExclude = + localExcludeBeforeSync ?? + ensureLocalSourceMarkerExclude(pathIdentity.canonical_path); + if (!localExclude.ok) { + return { + result: repositoryResult( + "error", + "verification_failed", + "applied_unverified", + { + failing_step: "source_marker_exclude", + source_id: input.sourceId, + git_head: input.head, + detail: localExclude.detail, + }, + ), + exitCode: 1, + }; + } + const postRepositoryState = ( + input.readRepositoryState ?? readCurrentRepositoryState + )(pathIdentity.canonical_path); + const attachedSource = ( + input.readAttachedSource ?? readCurrentAttachedSource + )(pathIdentity.canonical_path); + if ( + !workingTreeCleanBeforeSync || + postRepositoryState.head !== input.head || + postRepositoryState.head_after !== input.head || + !postRepositoryState.stable || + !postRepositoryState.clean || + postRepositoryState.source_marker_tracked !== false || + !attachedSource.trustworthy || + attachedSource.sourceId !== input.sourceId + ) { + return { + result: repositoryResult( + "error", + "verification_failed", + "applied_unverified", + { + failing_step: "post_attach_repository_state", + source_id: input.sourceId, + git_head: input.head, + working_tree_clean_before_apply: workingTreeCleanBeforeSync, + post_repository_state: postRepositoryState, + attached_source: attachedSource, + }, + ), + exitCode: 1, + }; + } + + const finalSourceProbe = input.spawnGbrain(["sources", "list", "--json"], { + timeout: 30_000, + baseEnv: gbrainEnv, + }); + const finalSourceRaw = + finalSourceProbe.status === 0 + ? parseJsonDocument(finalSourceProbe.stdout || "") + : null; + const finalSnapshot = + finalSourceRaw === null ? null : parseStrictSourceSnapshot(finalSourceRaw); + const finalSource = + finalSnapshot?.ok === true + ? finalSnapshot.rows.find((row) => row.id === input.sourceId) + : undefined; + const finalEquivalentOwners = + finalSnapshot?.ok === true + ? equivalentSourceOwnerIds( + finalSnapshot.rows, + pathIdentity.canonical_path, + input.platform ?? process.platform, + ) + : []; + const finalPathIdentity = finalSource?.local_path + ? classifyRepositoryPath( + finalSource.local_path, + pathIdentity.canonical_path, + input.platform ?? process.platform, + ) + : null; + if ( + !finalSource || + finalEquivalentOwners.length !== 1 || + finalEquivalentOwners[0] !== input.sourceId || + finalPathIdentity?.kind !== "equivalent" || + finalSource.last_commit !== input.head || + finalSource.last_successful_strategy !== "auto" + ) { + return { + result: repositoryResult( + "error", + "verification_failed", + "applied_unverified", + { + failing_step: "post_attach_source_snapshot", + source_id: input.sourceId, + existing_source_ids: finalEquivalentOwners, + git_head: input.head, + detail: + finalSnapshot && !finalSnapshot.ok + ? finalSnapshot.error + : (finalSourceProbe.stderr || "").trim(), + }, + ), + exitCode: 1, + }; + } + + const verified = repositoryResult( + "verified", + child.value.status === "up_to_date" ? "up_to_date" : "verified", + "applied_verified", + { + verification_scope: "content_sync", + gbrain_version: detectedText, + required_gbrain_version: REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION, + source: { + id: input.sourceId, + stored_path: finalSource.local_path, + canonical_path: pathIdentity.canonical_path, + path_identity: "equivalent", + }, + repository: { + git_head: postRepositoryState.head, + working_tree_clean: postRepositoryState.clean, + bookmark_before: source.last_commit, + target_commit: input.head, + bookmark_after: child.value.bookmarkAfter, + last_successful_strategy: "auto", + }, + sync: { + terminal_status: child.value.status, + strategy: "auto", + plan_digest: child.value.planDigest, + ...child.value.operations, + affected: { + ...child.value.affected, + sha256: child.value.affectedDigest, + }, + }, + corpus: child.value.corpus, + verification: { + source_path_matches: true, + bookmark_matches_clean_head: true, + attached_source_matches: true, + local_exclude_path: localExclude.path, + trusted: true, + }, + }, + ); + try { + (input.writeReceipt ?? writeRepositoryIndexReceipt)(receiptPath, verified); + } catch (error) { + return { + result: repositoryResult( + "error", + "verification_failed", + "applied_unverified", + { + failing_step: "receipt_write", + source_id: input.sourceId, + git_head: input.head, + receipt_path: receiptPath, + detail: error instanceof Error ? error.message : String(error), + }, + ), + exitCode: 1, + }; + } + try { + (input.clearAttemptMarker ?? clearRepositoryIndexAttemptMarker)( + attemptPath, + ); + } catch (error) { + return { + result: repositoryResult( + "error", + "verification_failed", + "applied_unverified", + { + failing_step: "attempt_watermark_clear", + source_id: input.sourceId, + git_head: input.head, + attempt_path: attemptPath, + receipt_path: receiptPath, + detail: error instanceof Error ? error.message : String(error), + }, + ), + exitCode: 1, + }; + } + + return { result: verified, exitCode: 0 }; +} + +export function renderRepositoryIndexResult( + result: RepositoryIndexResult, +): string { + if (result.status === "preview_ready") { + return [ + "ORCHESTRATION PREVIEW — unvalidated", + "GBrain was not contacted.", + "No engine/source/path/content compatibility was proven.", + `Required GBrain: >= ${REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION}`, + "Prerequisites:", + " 1. gbrain --version", + " 2. gbrain sources list --json", + " 3. Stop if version, source, path, or bookmark cannot be proven.", + `Command state: ${result.reason_code}`, + `Fallback: use repository files and rg`, + `Docs: ${result.docs}`, + ].join("\n"); + } + + const problems: Record = { + lock_busy: + "Another repository-index lifecycle currently owns the wrapper lock.", + unsupported_version: + "The installed GBrain release cannot prove the repository-index safety contract.", + source_probe_failed: + "The registered-source snapshot was unavailable, malformed, or ambiguous.", + source_registered: + "The source was registered safely, but no content plan was applied.", + source_path_different: + "The source id is registered to a different canonical directory.", + source_path_ambiguous: + "The stored source path cannot be proven equivalent to this repository.", + unsupported_path: + "The repository path is unsafe for the current Windows shell transport.", + sync_partial: "GBrain reported a partial repository-index application.", + sync_blocked: "GBrain refused or blocked the repository-index application.", + sync_failed: + "GBrain did not produce a trustworthy completed repository-index result.", + source_result_invalid: + "GBrain output did not match the one-document schema-1 completion contract.", + receipt_missing: + "No persisted repository-index receipt is available for this worktree.", + receipt_invalid: + "The persisted repository-index receipt is malformed or untrusted.", + receipt_stale: + "The persisted receipt does not match this live canonical worktree and clean full HEAD.", + receipt_in_progress: "The repository-index wrapper lock is active.", + receipt_superseded: + "A newer repository-index attempt invalidated the prior GREEN receipt and did not replace it.", + verification_failed: + "Content may have been applied, but a required postcondition did not verify.", + verified: + "The repository index was applied and verified against the current clean HEAD.", + up_to_date: + "The repository index already matched the current clean HEAD and verified.", + }; + const requirements: Record = { + unsupported_version: `A strict released version >= ${REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION}.`, + source_probe_failed: + "One valid unique sources snapshot with id, local_path, last_commit, and strategy evidence.", + source_registered: + "A second invocation after registration so expected bookmark state can be planned.", + source_path_different: + "Canonical filesystem identity; no automatic remove/re-add recovery.", + source_path_ambiguous: + "An existing readable directory canonically identical to the Git root.", + unsupported_path: + "A Windows repository path without whitespace or cmd.exe metacharacters until shell transport is replaced.", + sync_partial: "A terminal synced, first_sync, or up_to_date child result.", + sync_blocked: + "Satisfied expected root, target, bookmark, and source-lock preconditions.", + sync_failed: "A terminal child result with exact expected-state evidence.", + source_result_invalid: + "Exactly one schema-1 gbrain_sync JSON document and a recognized status.", + receipt_missing: + "A successful current repository-index invocation before receipt verification.", + receipt_invalid: + "A trusted schema-1 receipt written atomically by the repository-index wrapper.", + receipt_stale: + "Exact equality with the live canonical root, attached source, clean full HEAD, and receipt evidence.", + receipt_in_progress: + "No active wrapper lock before and after live receipt rebinding.", + receipt_superseded: + "A new fully verified repository-index run that writes a replacement receipt while the watermark remains, then clears the watermark to reveal it.", + verification_failed: + "Post-sync source bookmark/strategy, attach, clean HEAD, and atomic receipt must all verify.", + }; + const stateExplanations: Record = { + none: "No durable repository-index state was changed by this invocation.", + lock_only: + "Only transient lifecycle-lock state may have changed; no content was applied.", + registry_only: + "Only source registration changed; sync and attach were not attempted.", + partial: + "Repository-index support metadata or content may have changed, but full completion was not proven.", + applied_unverified: + "Index work may be complete, but no trusted GREEN receipt can be claimed.", + applied_verified: + "Index work, source marker, clean-HEAD bookmark, and receipt all verified.", + }; + return [ + `${result.status === "verified" ? "OK" : "ERROR"} [${result.reason_code}]`, + `Problem: ${problems[result.reason_code] ?? "The repository-index contract did not complete."}`, + `Observed: ${JSON.stringify(result.evidence)}`, + `Required: ${requirements[result.reason_code] ?? "All repository-index safety and verification invariants."}`, + `State changed: ${result.state_changed}`, + `State explanation: ${stateExplanations[result.state_changed]}`, + `Next command: ${result.next_command ?? "none — owner review required"}`, + "Fallback: use repository files and rg", + `Docs: ${result.docs}`, + ].join("\n"); +} diff --git a/make-pdf/SKILL.md b/make-pdf/SKILL.md index 600eb47ca4..7d690bb28c 100644 --- a/make-pdf/SKILL.md +++ b/make-pdf/SKILL.md @@ -378,33 +378,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/office-hours/SKILL.md b/office-hours/SKILL.md index 83161b8ca9..67242b1343 100644 --- a/office-hours/SKILL.md +++ b/office-hours/SKILL.md @@ -503,33 +503,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/open-gstack-browser/SKILL.md b/open-gstack-browser/SKILL.md index 28fb1ddb27..86c300bc3a 100644 --- a/open-gstack-browser/SKILL.md +++ b/open-gstack-browser/SKILL.md @@ -465,33 +465,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/package.json b/package.json index 846438a603..3f4076a6ec 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "gstack", - "version": "1.60.1.0", + "version": "1.63.0.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", "bin": { "browse": "./browse/dist/browse", + "gstack-gbrain-sync": "./bin/gstack-gbrain-sync", "make-pdf": "./make-pdf/dist/pdf" }, "scripts": { diff --git a/pair-agent/SKILL.md b/pair-agent/SKILL.md index eed9d171af..53dc67a59d 100644 --- a/pair-agent/SKILL.md +++ b/pair-agent/SKILL.md @@ -467,33 +467,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/plan-ceo-review/SKILL.md b/plan-ceo-review/SKILL.md index 3d3208bee6..4f53f6b6bf 100644 --- a/plan-ceo-review/SKILL.md +++ b/plan-ceo-review/SKILL.md @@ -497,33 +497,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/plan-design-review/SKILL.md b/plan-design-review/SKILL.md index e81f7f12af..45e4b890c6 100644 --- a/plan-design-review/SKILL.md +++ b/plan-design-review/SKILL.md @@ -469,33 +469,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/plan-devex-review/SKILL.md b/plan-devex-review/SKILL.md index 20a32da8bb..33d6d8c911 100644 --- a/plan-devex-review/SKILL.md +++ b/plan-devex-review/SKILL.md @@ -475,33 +475,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/plan-eng-review/SKILL.md b/plan-eng-review/SKILL.md index 5557a33fa0..1d0ba0f32c 100644 --- a/plan-eng-review/SKILL.md +++ b/plan-eng-review/SKILL.md @@ -473,33 +473,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/plan-tune/SKILL.md b/plan-tune/SKILL.md index f49b66fac3..0ca8c527e5 100644 --- a/plan-tune/SKILL.md +++ b/plan-tune/SKILL.md @@ -478,33 +478,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/qa-only/SKILL.md b/qa-only/SKILL.md index 801a935c06..49c7a26d4e 100644 --- a/qa-only/SKILL.md +++ b/qa-only/SKILL.md @@ -468,33 +468,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/qa/SKILL.md b/qa/SKILL.md index c1ac10253f..a954628a13 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -474,33 +474,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/retro/SKILL.md b/retro/SKILL.md index 3fbc447261..8dc322694f 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -485,33 +485,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/review/SKILL.md b/review/SKILL.md index 5f26e2e426..878f750524 100644 --- a/review/SKILL.md +++ b/review/SKILL.md @@ -470,33 +470,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/scrape/SKILL.md b/scrape/SKILL.md index dc965ec5f7..8eea745477 100644 --- a/scrape/SKILL.md +++ b/scrape/SKILL.md @@ -466,33 +466,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/scripts/proactive-suggestions.json b/scripts/proactive-suggestions.json index d08c608533..3022976360 100644 --- a/scripts/proactive-suggestions.json +++ b/scripts/proactive-suggestions.json @@ -264,8 +264,8 @@ "voice_line": null }, "sync-gbrain": { - "lead": "Keep gbrain current with this repo's code and refresh agent search guidance in CLAUDE.md. Wraps the gstack-gbrain-sync orchestrator with state", - "routing": "probing, native code-surface registration, capability checks,\nand a verdict block. Re-runnable, idempotent. Use when: \"sync gbrain\",\n\"refresh gbrain\", \"re-index this repo\", \"gbrain search isn't finding\nthings\".", + "lead": "Keep gbrain current with this repo's code and tracked Markdown, and refresh agent search guidance in CLAUDE.md. Wraps the gstack-gbrain-sync", + "routing": "orchestrator with\nstrict version/source/path preflight, expected-state sync, a bounded\nreceipt, capability checks, and a verdict block. Use when: \"sync gbrain\",\n\"refresh gbrain\", \"re-index this repo\", \"gbrain search isn't finding\nthings\".", "voice_line": null }, "unfreeze": { diff --git a/scripts/resolvers/preamble/generate-brain-sync-block.ts b/scripts/resolvers/preamble/generate-brain-sync-block.ts index 1486f3c3f4..bd108479f1 100644 --- a/scripts/resolvers/preamble/generate-brain-sync-block.ts +++ b/scripts/resolvers/preamble/generate-brain-sync-block.ts @@ -2,9 +2,9 @@ * artifacts-sync preamble block (renamed from gbrain-sync in v1.27.0.0). * * Emits bash that runs at every skill invocation: - * 0. Live gbrain-availability hint (per /plan-eng-review): when gbrain is - * configured, emit one of two variants (steady-state vs empty-corpus - * emergency). Zero context cost when gbrain is not configured. + * 0. Live repository-receipt gate: when gbrain is configured, recommend + * repository search only after the bounded receipt verifies against the + * current clean HEAD; otherwise emit the files/rg fallback. * 1. If ~/.gstack-artifacts-remote.txt (or legacy ~/.gstack-brain-remote.txt * during the v1.27.0.0 migration window) exists AND ~/.gstack/.git is * missing, surface a restore-available hint (does NOT auto-run restore). @@ -27,10 +27,10 @@ * Skill-end sync is handled by the completion-status generator via a call * to `gstack-brain-sync --discover-new` + `--once`. */ -import type { TemplateContext } from '../types'; +import type { TemplateContext } from "../types"; export function generateBrainSyncBlock(ctx: TemplateContext): string { - const isBrainHost = ctx.host === 'gbrain' || ctx.host === 'hermes'; + const isBrainHost = ctx.host === "gbrain" || ctx.host === "hermes"; return `## Artifacts Sync (skill start) \`\`\`bash @@ -44,33 +44,32 @@ else fi _BRAIN_SYNC_BIN="${ctx.paths.binDir}/gstack-brain-sync" _BRAIN_CONFIG_BIN="${ctx.paths.binDir}/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=${ctx.paths.binDir}/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style \`.gbrain-source\` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \\\`gbrain search\\\`/\\\`gbrain query\\\` over Grep for" - echo "semantic questions; use \\\`gbrain code-def\\\`/\\\`code-refs\\\`/\\\`code-callers\\\` for" - echo "symbol-aware code lookup. See \\"## GBrain Search Guidance\\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \\\`/sync-gbrain --full\\\`" - echo "before relying on \\\`gbrain search\\\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \\\`gbrain search\\\`/\\\`gbrain query\\\`" + echo "for semantic questions and \\\`gbrain code-def\\\`/\\\`code-refs\\\`/\\\`code-callers\\\`" + echo "for symbol-aware lookup. See \\"## GBrain Search Guidance\\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \\\`rg\\\`; run \\\`/sync-gbrain --code-only\\\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) @@ -128,7 +127,7 @@ else fi \`\`\` -${isBrainHost ? `If output shows \`ARTIFACTS_SYNC: artifacts repo detected\`, offer \`gstack-brain-restore\` via AskUserQuestion; otherwise continue.` : ''} +${isBrainHost ? `If output shows \`ARTIFACTS_SYNC: artifacts repo detected\`, offer \`gstack-brain-restore\` via AskUserQuestion; otherwise continue.` : ""} Privacy stop-gate: if output shows \`ARTIFACTS_SYNC: off\`, \`artifacts_sync_mode_prompted\` is \`false\`, and gbrain is on PATH or \`gbrain doctor --fast --json\` works, ask once: diff --git a/setup-browser-cookies/SKILL.md b/setup-browser-cookies/SKILL.md index 77df27da2c..b65af610cd 100644 --- a/setup-browser-cookies/SKILL.md +++ b/setup-browser-cookies/SKILL.md @@ -337,33 +337,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/setup-deploy/SKILL.md b/setup-deploy/SKILL.md index 3465dc564b..43c10de869 100644 --- a/setup-deploy/SKILL.md +++ b/setup-deploy/SKILL.md @@ -469,33 +469,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/setup-gbrain/SKILL.md b/setup-gbrain/SKILL.md index a3892f1c90..6d9e8f3df5 100644 --- a/setup-gbrain/SKILL.md +++ b/setup-gbrain/SKILL.md @@ -468,33 +468,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) @@ -1325,10 +1324,20 @@ current_tier=$(~/.claude/skills/gstack/bin/gstack-gbrain-repo-policy get) ``` Branches: -- `read-write` → import this repo: `gbrain import "$(pwd)" --no-embed` then - `gbrain embed --stale &` in the background. -- `read-only` → skip import entirely (this tier is enforced by the future - auto-import hook + by gbrain resolver injection, not here). +- `read-write` → run the safe repository-index wrapper exactly once: + ```bash + _GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync + "$_GBRAIN_REPOSITORY_SYNC_BIN" --code-only --json + ``` + Never substitute a raw `gbrain import`, background `gbrain embed`, or direct + `gbrain sync`. The wrapper enforces the strict version/source/path gate and + expected-state contract, including a validated dry-run whose full + `plan_digest` is required and echoed by apply. A first invocation may + intentionally return exit 2 with `reason_code: source_registered` and + `state_changed: registry_only`; + report that bootstrap state and tell the user to run + `/sync-gbrain --code-only` again before relying on repository search. +- `read-only` → skip repository indexing entirely. - `deny` → do nothing. - `unset` → AskUserQuestion: "How should `` interact with gbrain?" @@ -1341,7 +1350,7 @@ Branches: ```bash ~/.claude/skills/gstack/bin/gstack-gbrain-repo-policy set "$REMOTE" "$TIER" ``` - Then import iff `read-write`. + Then run the safe repository-index wrapper above iff `read-write`. If outside a git repo OR no origin remote: skip this step with a note. @@ -1486,15 +1495,16 @@ Options: After answer: ```bash ~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode -~/.claude/skills/gstack/bin/gstack-gbrain-sync --full --no-brain-sync +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync +"$_GBRAIN_REPOSITORY_SYNC_BIN" --full --no-brain-sync ``` (`--no-brain-sync` because Step 7 already wired that path; this just -runs the code import + memory ingest stages. Brain-sync will run on the +runs the repository-index + memory-ingest stages. Brain-sync will run on the next preamble hook.) -If A/D/E, ingest is incremental from this point on; preamble-boundary -hook runs `gstack-gbrain-sync --incremental --quiet` on every skill -start (cheap mtime fast-path). +If A/D/E, transcript ingest is incremental from this point on. Repository +indexing is not implied by a later skill start; run `/sync-gbrain` after +committed source or tracked-Markdown changes. Reference doc for users: `setup-gbrain/memory.md` (linked from CLAUDE.md Step 8). @@ -1537,49 +1547,12 @@ in to git in many projects). It lives only in `~/.claude.json` where - Current repo policy: {read-write|read-only|deny|unset} ``` -**After Step 9 (smoke test) passes, also write the `## GBrain Search Guidance` -block** so the coding agent learns when to prefer `gbrain` over Grep. This -block is gated on the smoke test passing — write the Configuration block -first (so the user knows what state they're in even if the smoke test fails), -then return here after Step 9 and write the guidance block only if smoke -test succeeded. - -When Step 9 passes, find-and-replace (or append) this block. Use HTML-comment -delimiters so removal regex is unambiguous and never eats user content. The -block content is machine-AGNOSTIC — no engine type, no page counts, no -last-sync time. Machine state stays in the Configuration block above. - -```markdown -## GBrain Search Guidance (configured by /sync-gbrain) - - -GBrain is set up and synced on this machine. The agent should prefer gbrain -over Grep when the question is semantic or when you don't know the exact -identifier yet. Two indexed corpora available via the `gbrain` CLI: -- This repo's code (registered as `gstack-code-` source). -- `~/.gstack/` curated memory (registered as `gstack-brain-` source via - the existing federation pipeline). - -Prefer gbrain when: -- "Where is X handled?" / semantic intent, no exact string yet: - `gbrain search ""` or `gbrain query ""` -- "Where is symbol Y defined?" / symbol-based code questions: - `gbrain code-def ` or `gbrain code-refs ` -- "What calls Y?" / "What does Y depend on?": - `gbrain code-callers ` / `gbrain code-callees ` -- "What did we decide last time?" / past plans, retros, learnings: - `gbrain search "" --source gstack-brain-` - -Grep is still right for known exact strings, regex, multiline patterns, and -file globs. The brain auto-syncs incrementally on every gstack skill start. -Run `/sync-gbrain` to force-refresh, `/sync-gbrain --full` for full reindex. - - -``` - -If Step 9 smoke test fails, skip the guidance block write entirely. The user's -next `/sync-gbrain` run will re-evaluate capability and write the block when -the round-trip works. +Do **not** create or refresh `## GBrain Search Guidance` in this setup skill. +A generic engine/MCP smoke test proves availability, not that this worktree's +repository index matches its current clean HEAD. `/sync-gbrain` owns that +guidance and may write it only after its repository-index run produces a +trusted receipt that passes the live `--verify-receipt` check. Until then, +tell the agent to use repository files and `rg`. --- diff --git a/setup-gbrain/SKILL.md.tmpl b/setup-gbrain/SKILL.md.tmpl index f475987c14..646defede3 100644 --- a/setup-gbrain/SKILL.md.tmpl +++ b/setup-gbrain/SKILL.md.tmpl @@ -571,10 +571,20 @@ current_tier=$(~/.claude/skills/gstack/bin/gstack-gbrain-repo-policy get) ``` Branches: -- `read-write` → import this repo: `gbrain import "$(pwd)" --no-embed` then - `gbrain embed --stale &` in the background. -- `read-only` → skip import entirely (this tier is enforced by the future - auto-import hook + by gbrain resolver injection, not here). +- `read-write` → run the safe repository-index wrapper exactly once: + ```bash + _GBRAIN_REPOSITORY_SYNC_BIN={{BIN_DIR}}/gstack-gbrain-sync + "$_GBRAIN_REPOSITORY_SYNC_BIN" --code-only --json + ``` + Never substitute a raw `gbrain import`, background `gbrain embed`, or direct + `gbrain sync`. The wrapper enforces the strict version/source/path gate and + expected-state contract, including a validated dry-run whose full + `plan_digest` is required and echoed by apply. A first invocation may + intentionally return exit 2 with `reason_code: source_registered` and + `state_changed: registry_only`; + report that bootstrap state and tell the user to run + `/sync-gbrain --code-only` again before relying on repository search. +- `read-only` → skip repository indexing entirely. - `deny` → do nothing. - `unset` → AskUserQuestion: "How should `` interact with gbrain?" @@ -587,7 +597,7 @@ Branches: ```bash ~/.claude/skills/gstack/bin/gstack-gbrain-repo-policy set "$REMOTE" "$TIER" ``` - Then import iff `read-write`. + Then run the safe repository-index wrapper above iff `read-write`. If outside a git repo OR no origin remote: skip this step with a note. @@ -732,15 +742,16 @@ Options: After answer: ```bash ~/.claude/skills/gstack/bin/gstack-config set transcript_ingest_mode -~/.claude/skills/gstack/bin/gstack-gbrain-sync --full --no-brain-sync +_GBRAIN_REPOSITORY_SYNC_BIN={{BIN_DIR}}/gstack-gbrain-sync +"$_GBRAIN_REPOSITORY_SYNC_BIN" --full --no-brain-sync ``` (`--no-brain-sync` because Step 7 already wired that path; this just -runs the code import + memory ingest stages. Brain-sync will run on the +runs the repository-index + memory-ingest stages. Brain-sync will run on the next preamble hook.) -If A/D/E, ingest is incremental from this point on; preamble-boundary -hook runs `gstack-gbrain-sync --incremental --quiet` on every skill -start (cheap mtime fast-path). +If A/D/E, transcript ingest is incremental from this point on. Repository +indexing is not implied by a later skill start; run `/sync-gbrain` after +committed source or tracked-Markdown changes. Reference doc for users: `setup-gbrain/memory.md` (linked from CLAUDE.md Step 8). @@ -783,49 +794,12 @@ in to git in many projects). It lives only in `~/.claude.json` where - Current repo policy: {read-write|read-only|deny|unset} ``` -**After Step 9 (smoke test) passes, also write the `## GBrain Search Guidance` -block** so the coding agent learns when to prefer `gbrain` over Grep. This -block is gated on the smoke test passing — write the Configuration block -first (so the user knows what state they're in even if the smoke test fails), -then return here after Step 9 and write the guidance block only if smoke -test succeeded. - -When Step 9 passes, find-and-replace (or append) this block. Use HTML-comment -delimiters so removal regex is unambiguous and never eats user content. The -block content is machine-AGNOSTIC — no engine type, no page counts, no -last-sync time. Machine state stays in the Configuration block above. - -```markdown -## GBrain Search Guidance (configured by /sync-gbrain) - - -GBrain is set up and synced on this machine. The agent should prefer gbrain -over Grep when the question is semantic or when you don't know the exact -identifier yet. Two indexed corpora available via the `gbrain` CLI: -- This repo's code (registered as `gstack-code-` source). -- `~/.gstack/` curated memory (registered as `gstack-brain-` source via - the existing federation pipeline). - -Prefer gbrain when: -- "Where is X handled?" / semantic intent, no exact string yet: - `gbrain search ""` or `gbrain query ""` -- "Where is symbol Y defined?" / symbol-based code questions: - `gbrain code-def ` or `gbrain code-refs ` -- "What calls Y?" / "What does Y depend on?": - `gbrain code-callers ` / `gbrain code-callees ` -- "What did we decide last time?" / past plans, retros, learnings: - `gbrain search "" --source gstack-brain-` - -Grep is still right for known exact strings, regex, multiline patterns, and -file globs. The brain auto-syncs incrementally on every gstack skill start. -Run `/sync-gbrain` to force-refresh, `/sync-gbrain --full` for full reindex. - - -``` - -If Step 9 smoke test fails, skip the guidance block write entirely. The user's -next `/sync-gbrain` run will re-evaluate capability and write the block when -the round-trip works. +Do **not** create or refresh `## GBrain Search Guidance` in this setup skill. +A generic engine/MCP smoke test proves availability, not that this worktree's +repository index matches its current clean HEAD. `/sync-gbrain` owns that +guidance and may write it only after its repository-index run produces a +trusted receipt that passes the live `--verify-receipt` check. Until then, +tell the agent to use repository files and `rg`. --- diff --git a/ship/SKILL.md b/ship/SKILL.md index eadffaa8f6..bfbfa6ff0b 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -470,33 +470,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/skillify/SKILL.md b/skillify/SKILL.md index 7cb434d0ca..d17c5bfa25 100644 --- a/skillify/SKILL.md +++ b/skillify/SKILL.md @@ -466,33 +466,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/spec/SKILL.md b/spec/SKILL.md index 0894e98d18..a7f0a1e531 100644 --- a/spec/SKILL.md +++ b/spec/SKILL.md @@ -467,33 +467,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) @@ -1537,33 +1536,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/sync-gbrain/SKILL.md b/sync-gbrain/SKILL.md index 02055bb6f8..87cdf4816e 100644 --- a/sync-gbrain/SKILL.md +++ b/sync-gbrain/SKILL.md @@ -2,7 +2,7 @@ name: sync-gbrain preamble-tier: 2 version: 1.0.0 -description: Keep gbrain current with this repo's code and refresh agent search guidance in CLAUDE.md. Wraps the gstack-gbrain-sync orchestrator with state (gstack) +description: Keep gbrain current with this repo's code and tracked Markdown, and refresh agent search guidance in CLAUDE.md. Wraps the gstack-gbrain-sync (gstack) triggers: - sync gbrain - refresh gbrain @@ -23,8 +23,9 @@ allowed-tools: ## When to invoke this skill -probing, native code-surface registration, capability checks, -and a verdict block. Re-runnable, idempotent. Use when: "sync gbrain", +orchestrator with +strict version/source/path preflight, expected-state sync, a bounded +receipt, capability checks, and a verdict block. Use when: "sync gbrain", "refresh gbrain", "re-index this repo", "gbrain search isn't finding things". @@ -468,33 +469,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) @@ -784,41 +784,74 @@ Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXI You are running the canonical "keep this brain up to date" verb. /setup-gbrain installs gbrain once; /sync-gbrain runs every time the user wants the brain -refreshed against this repo's current state, and refreshes the agent-side +refreshed against this repo's committed code and tracked Markdown, and refreshes the agent-side guidance in CLAUDE.md so the coding agent knows when to prefer `gbrain` search over Grep. -**Architecture (post-codex review):** This skill uses gbrain v0.20.0+'s -**native code surfaces** (`gbrain sources add`, `gbrain sync --strategy code`, -`gbrain reindex-code`, `gbrain code-def/code-refs/code-callers/code-callees`). -It does NOT use `gbrain import` (that path is for markdown directories). +**Repository-index architecture:** This skill requires GBrain +`>= 0.42.71.0` and uses its expected-state repository surface +(`gbrain sync --strategy auto --no-pull --expected-target +--expected-bookmark --json`). It first requires a schema-1 +`validated_index_plan`, then applies only with that preview's exact +`--expected-plan-digest` and requires the successful apply result to echo the +same digest. It indexes admitted code plus tracked Markdown and writes a +clean-HEAD receipt only after source/bookmark, wrapper-owned marker, final +source-reread, and live worktree verification. It does NOT touch `~/.gstack/` indexing (the existing `gstack-gbrain-source-wireup` owns that — never double-store). +This repository index is distinct from **GStack artifact sync**, which moves a +curated subset of `~/.gstack/` through a private Git repository. The artifact +workflow is documented in `docs/gbrain-sync.md`; repository-index recovery is +documented in `docs/repository-index-recovery.md`. + ## User-invocable When the user types `/sync-gbrain`, run this skill. Argument modes (parsed by the skill itself, not a dispatcher binary): -- `/sync-gbrain` — incremental sync (default; mtime fast-path; ~50ms steady-state) -- `/sync-gbrain --full` — full code reindex via `gbrain reindex-code` (~25-35 min on a big repo). Auto-builds the call graph (`gbrain dream`) **only when it was never built**. +- `/sync-gbrain` — expected-state repository sync plus enabled downstream memory/artifact stages +- `/sync-gbrain --full` — repository sync plus full downstream maintenance. Auto-builds the call graph (`gbrain dream`) **only when it was never built**. - `/sync-gbrain --dream` — build this source's call graph (`gbrain code-callers`/`code-callees`) via a source-scoped `gbrain dream --source ` cycle; ~minutes; runs lock-free after the sync stages. Always forces, even if already built. Only produces a graph on a code-aware schema pack; otherwise the run reports a WARN explaining why the graph is still empty. - `/sync-gbrain --no-dream` — skip the dream cycle that `--full` would otherwise auto-run. -- `/sync-gbrain --code-only` — only run the code stage; skip memory + brain-sync -- `/sync-gbrain --dry-run` — preview what would sync; no writes anywhere +- `/sync-gbrain --code-only` — legacy flag name: run only the repository-index stage (code + tracked Markdown); skip memory + artifact sync +- `/sync-gbrain --dry-run` — `ORCHESTRATION PREVIEW — unvalidated`; no Git/engine/source/path/content probes and no writes +- `/sync-gbrain --json` — emit exactly one schema-1 `repository_index` JSON document +- `/sync-gbrain --verify-receipt` — read-only proof that the persisted GREEN receipt matches this live canonical worktree and clean full HEAD - `/sync-gbrain --no-memory` / `--no-brain-sync` — selectively skip stages - `/sync-gbrain --quiet` — suppress per-stage output - `/sync-gbrain --refresh-cache` — force-rebuild brain-aware planning cache (v1.48; replaces /brain-refresh-context per D1 fold). Skips code + memory stages; routes to `gstack-brain-cache refresh --project `. - `/sync-gbrain --audit` — emit summary of gstack-owned pages per project + sensitive-content audit (v1.48 / D10 lifecycle). Read-only. Pass-through args go straight to the orchestrator at -`~/.claude/skills/gstack/bin/gstack-gbrain-sync.ts`. +`~/.claude/skills/gstack/bin/gstack-gbrain-sync`. + +### `--dry-run` assurance short-circuit + +When `--dry-run` is present, run the orchestrator immediately and STOP this +skill after showing its result. Do not run Step 1 detection, trust prompts, +engine/source probes, capability writes, CLAUDE.md edits, health checks, +artifact sync, or dream. + +The first line must be exactly: + +```text +ORCHESTRATION PREVIEW — unvalidated +``` + +This is a no-probe wrapper preview. It proves no installed version, source, +canonical path, bookmark, or content plan, and its machine reason is +`blocked_until_version_proven` with `state_changed: none`. Never print an +unconditional raw GBrain apply command from this mode. A validated direct +content preview is allowed only after proving GBrain `>= 0.42.71.0`, the unique +source, canonical root, full HEAD, and bookmark; use the exact recovery command +in `docs/repository-index-recovery.md`. **`--refresh-cache` short-circuit:** when this flag is present, the skill runs ONLY the cache refresh (`gstack-brain-cache refresh --project ` for the current worktree's slug, plus a cross-project refresh of -user-profile if `gstack/user-profile/` exists). Code + -memory + brain-sync stages are skipped. Useful when the user knows the +user-profile if `gstack/user-profile/` exists). Repository index, +memory ingest, and artifact-sync stages are skipped. Useful when the user knows the brain has new info gstack should pick up before the next planning skill. **`--audit` short-circuit:** when this flag is present, the skill runs @@ -860,18 +893,19 @@ If `_POLICY == "unset"` AND `_HASH == "local"`, auto-set personal: ~/.claude/skills/gstack/bin/gstack-config set brain_trust_policy@$_HASH personal ``` -**Split-engine model (v1.34.0.0+).** Code stage runs locally against the +**Split-engine model (v1.34.0.0+).** Repository-index stage runs locally against the per-machine gbrain engine (PGLite or whatever `gbrain config` points to), with each worktree of a repo registered as its own source. **Memory stage also runs locally** in local-stdio MCP mode — `gstack-memory-ingest` shells out to `gbrain import` against the same local engine. In remote-http MCP mode (Path 4), the memory stage instead persists staged markdown to `~/.gstack/transcripts//` and the artifacts pipeline pushes it to -the brain admin's pull job (plan D11). Brain-sync (the `gstack-brain-sync` -push to git) is the one stage that never touches local engine and runs -regardless of mode. +the brain admin's pull job (plan D11). GStack artifact sync +(`gstack-brain-sync`, a Git push) never touches the local engine, but it does +not run past a terminal repository-index boundary unless repository indexing +was explicitly skipped. -Practically: local PGLite stays code-only on remote-http machines; the +Practically: local PGLite stays repository-index-only on remote-http machines; the remote brain holds everything else. Local-stdio machines mix code + transcripts in one local engine, as they always have. @@ -904,11 +938,10 @@ BEFORE invoking the orchestrator: symbol code search needs a local PGLite. Run `/setup-gbrain` and pick 'Yes' at the new 'local code index' prompt (Step 4.5), or run `gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024` - directly (drop the voyage flags if `VOYAGE_API_KEY` isn't set). Continuing - without code stage." - Then proceed to Step 2 — the orchestrator's `runCodeImport()` and - `runMemoryIngest()` will return SKIP per plan D12; only `runBrainSyncPush()` - will run. Do NOT abort. + directly (drop the voyage flags if `VOYAGE_API_KEY` isn't set)." + STOP unless the user explicitly passed `--no-code`. With `--no-code`, Step 2 + may run the non-repository stages; without it, a repository source snapshot + cannot be proven and the repository gate is terminal. - **`missing-config`** AND `gbrain_mcp_mode != "remote-http"`: STOP. "Local gbrain CLI is installed but no engine config. Run `/setup-gbrain` first." - **`broken-config`** OR **`broken-db`**: STOP with a clear message: @@ -922,13 +955,14 @@ BEFORE invoking the orchestrator: --embedding-dimensions 1024 (drop voyage flags if VOYAGE_API_KEY unset) Re-run /sync-gbrain after. ``` - Do NOT continue — the orchestrator would skip code+memory and only run - brain-sync, which is a degraded state the user should fix explicitly. + Do NOT continue — repository version/source/bookmark proof cannot complete + against a broken engine. If the user explicitly wants only Git artifact + transport, they must choose `--no-code --no-memory`. -This pre-flight short-circuits the orchestrator before it spends ~80ms -probing the engine again. The orchestrator independently runs the same -classifier for defense-in-depth, but Step 1.5's STOP is where the user -gets the actionable remediation message. +This skill-level pre-flight provides actionable remediation. The wrapper still +performs its own strict version and source snapshot checks and fails closed if +the configured engine cannot supply them; the memory stage retains its own +local-engine classifier. --- @@ -938,49 +972,91 @@ Pass user args to the orchestrator. Do not paraphrase them — pass through as-is. ```bash -bun run ~/.claude/skills/gstack/bin/gstack-gbrain-sync.ts +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync +"$_GBRAIN_REPOSITORY_SYNC_BIN" ``` -The orchestrator runs three stages: code → memory → brain-sync (per the -plan's storage tiering). Each stage failure is non-fatal; subsequent stages -still run. State is persisted to `~/.gstack/.gbrain-sync-state.json` via -tmp-file + atomic rename. Concurrent runs are blocked by a lock file at -`~/.gstack/.sync-gbrain.lock` (5-min stale-takeover). +Record the command's exit code. If the repository stage was enabled and the +command exits nonzero, STOP on that current result and do not consult a +previously persisted receipt. If the user passed `--no-code`, report the +repository stage as explicitly skipped; do not run Step 3 and do not claim a +repository GREEN from historical evidence. + +The orchestrator starts with repository index, then memory ingest and GStack +artifact sync. A repository refusal, partial apply, or unverified apply is +terminal: unrelated later stages, attach, cleanup, reindex, and dream do not +continue across that boundary. Concurrent real runs are blocked by +`~/.gstack/.sync-gbrain.lock`. The wrapper never auto-breaks an existing +lock: inspect its recorded PID and remove it only after you have independently +proved that no owner is running. Every real repository attempt also replaces +the prior receipt with a fail-closed attempt watermark before the first GBrain +probe. A fully verified run writes its new receipt while the watermark still +blocks readers, then clears the watermark to reveal that receipt. +For an existing source, the wrapper performs a strict GBrain dry-run under the +same lock, validates its bounded schema and full `plan_digest`, then invokes +apply with `--expected-plan-digest`. Missing, malformed, changed, or +non-echoed plan evidence is terminal and cannot publish a receipt. + +If the source is absent, the first real invocation performs only safe source +registration and exits 2 with `source_registered` / +`state_changed: registry_only`. Stop and report that result; run the same +command a second time only after registration is visible. Never convert this +two-invocation bootstrap into register-and-sync. + +On a clean verified run the repository receipt is written atomically to +`~/.gstack/.gbrain-repository-index-receipt.json`. Its GREEN scope is content +sync at the recorded HEAD—not a blanket claim that embeddings, extraction, or +semantic search are ready. --- -## Step 3: Code-index health check +## Step 3: Repository receipt check -After the sync run, query gbrain for the cwd source's page_count: +Page count is descriptive and must never turn the repository verdict GREEN. +After a zero-exit current invocation with the repository stage enabled, run +the read-only live-binding verifier: ```bash -SOURCE_ID=$(grep -o '"source_id":"[^"]*"' ~/.gstack/.gbrain-sync-state.json 2>/dev/null \ - | head -1 | sed 's/.*"source_id":"//;s/".*//') -PAGES=$(gbrain sources list --json 2>/dev/null \ - | jq -r --arg id "$SOURCE_ID" '.sources[] | select(.id==$id) | .page_count' 2>/dev/null \ - || echo 0) -echo "cwd source: $SOURCE_ID, page_count: $PAGES" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync +"$_GBRAIN_REPOSITORY_SYNC_BIN" \ + --verify-receipt --json ``` -If `PAGES` is 0 or empty AND the user did NOT pass `--no-code` AND mode was -not `--full`, AskUserQuestion via the format in the preamble: - -> D1 — This repo has 0 indexed pages in gbrain. Run a full code reindex now? -> -> ELI10: gbrain hasn't indexed this repo's code yet. The semantic search -> tools (`gbrain search`, `code-def`, `code-refs`) will return nothing -> until we run a full pass. Takes ~25-35 minutes on a big Mac. -> -> Recommendation: A — the brain is unusable for code search until indexed, -> and Step 2 of this skill already verified gbrain is configured correctly. -> -> Note: options differ in kind, not coverage — no completeness score. -> -> A) Run /sync-gbrain --full now (recommended) -> B) Skip — I'll run it later - -If A: re-invoke the orchestrator with `--full --code-only`. -If B: continue to Step 4 with the empty-corpus state recorded. +This command contacts neither GBrain nor the engine. It re-proves the live +canonical Git root, clean full HEAD, tracked-marker state, and attached source +id against the persisted schema-1 receipt. A stale receipt from this worktree, +another worktree, or an earlier successful invocation exits 1 with +`receipt_stale`; malformed or untrusted evidence returns `receipt_invalid`; +an active wrapper lock returns `receipt_in_progress`, and a fail-closed attempt +watermark left by a newer non-GREEN run returns `receipt_superseded`. + +Proceed only when all of these are true: + +- `result_kind == "repository_index"` +- `status == "verified"` +- `state_changed == "applied_verified"` +- source path identity is `equivalent` +- full `bookmark_after == git_head` +- `last_successful_strategy == "auto"` +- `sync.plan_digest` is a full lowercase SHA-256 shared by the validated + preview and successful apply +- the recorded tree was clean +- `image_operations_applied == 0` and multimodal admission was disabled +- `verification.trusted == true` +- the verifier itself exited 0 for the current canonical root and live full HEAD + +The receipt caps its affected sample at 100, binds the full canonical +operation/path/slug/rename-source set with SHA-256, and persists the immutable +plan digest that joined preview to apply. `image_operations_applied: 0` proves +no image work in this invocation, not an image-free historical corpus. +`search_ready` and embedding/extraction states are separate: content may be +current while semantic processing is deferred. + +If the receipt is missing or any invariant fails, report the wrapper's stable +`reason_code` and `state_changed`, link +`docs/repository-index-recovery.md`, and STOP repository-success claims. +`partial` and `applied_unverified` never attach or become GREEN by inspecting +page count. --- @@ -988,7 +1064,7 @@ If B: continue to Step 4 with the empty-corpus state recorded. `gbrain code-callers` / `code-callees` (who-calls-this / what-this-calls) return `count: 0` until a `gbrain dream` cycle runs the `resolve_symbol_edges` phase for -this source — not done by the code import in Step 2. +this source — not done by repository content sync in Step 2. **One hard prerequisite:** building a call graph requires this source's active **schema pack to extract code symbols** (the `extract_atoms` phase). On a pack @@ -1005,8 +1081,8 @@ Detect whether this source's call graph is built via doctor's `cycle_freshness` check, matching the cwd `SOURCE_ID` literally: ```bash -SOURCE_ID=$(grep -o '"source_id":"[^"]*"' ~/.gstack/.gbrain-sync-state.json 2>/dev/null \ - | head -1 | sed 's/.*"source_id":"//;s/".*//') +SOURCE_ID=$(jq -r '.evidence.source.id // empty' \ + ~/.gstack/.gbrain-repository-index-receipt.json 2>/dev/null) CYCLE=$(gbrain doctor --json --fast 2>/dev/null \ | jq -r --arg id "$SOURCE_ID" ' (.checks[] | select(.name=="cycle_freshness")) as $c @@ -1020,7 +1096,7 @@ echo "call graph for $SOURCE_ID: $CYCLE" ``` If `CYCLE == never` AND the user did NOT pass `--dream`/`--full` AND Step 3 -`PAGES > 0`, AskUserQuestion via the format in the preamble: +has a trusted repository receipt, AskUserQuestion via the format in the preamble: > D2 — This repo's call graph isn't built. Build it now? > @@ -1040,8 +1116,9 @@ If `CYCLE == never` AND the user did NOT pass `--dream`/`--full` AND Step 3 > A) Run /sync-gbrain --dream now (recommended) > B) Skip — I'll run it later -If A: re-invoke the orchestrator with `--dream --code-only` (skips memory + -brain-sync; the dream stage still runs because it's gated on `--dream`). Then +If A: re-invoke the orchestrator with `--dream --code-only` (skips memory +ingest + artifact sync; the dream stage still runs because it's gated on +`--dream`). Then report the dream stage's ACTUAL row — `OK call graph built (N edges)` vs a `WARN` that names why the graph is still empty (non-code-aware pack, missing embedding key, or 0 edges matched). Do not claim success on a WARN. @@ -1098,18 +1175,23 @@ Verbatim block content (copy exactly): ## GBrain Search Guidance (configured by /sync-gbrain) -GBrain is set up and synced on this machine. The agent should prefer gbrain -over Grep when the question is semantic or when you don't know the exact -identifier yet. +This checked-in block is guidance, not proof of live repository-index +freshness. Before relying on GBrain for this repository, the skill preamble +must run the host-resolved `gstack-gbrain-sync --verify-receipt --json --quiet` +launcher and observe all three exact markers: `"status":"verified"`, +`"state_changed":"applied_verified"`, and `"trusted":true`. Only then should +the agent prefer GBrain when the question is semantic or the exact identifier +is unknown. -**This worktree is pinned to a worktree-scoped code source** via the +**This worktree is pinned to a worktree-scoped repository source** via the `.gbrain-source` file in the repo root (kubectl-style context). `gbrain code-def`, `code-refs`, `code-callers`, `code-callees`, `search`, and `query` from anywhere under this worktree route to that source by default — no `--source` flag needed (gbrain >= 0.41.38.0; on older gbrain the call-graph commands need `--source "$(cat .gbrain-source)"`). Conductor sibling worktrees of the same repo each have their own pin and their own indexed pages, so -semantic results match the code on disk here. +semantic results are isolated by worktree. The pin alone never proves that +indexed content matches the current HEAD. Call-graph queries (`code-callers`/`code-callees`) also need the graph to be built first — run `/sync-gbrain --dream` (or `--full`) if they return @@ -1118,7 +1200,8 @@ symbols; on a non-code-aware pack `--dream` completes but the graph stays empty and reports a WARN. `code-def`/`code-refs` need the same extraction. Two indexed corpora available via the `gbrain` CLI: -- This worktree's code (auto-pinned via `.gbrain-source`). +- This worktree's admitted code and tracked Markdown (auto-pinned via + `.gbrain-source`). - `~/.gstack/` curated memory (registered as `gstack-brain-` source via the existing federation pipeline). @@ -1132,16 +1215,19 @@ Prefer gbrain when: - "What did we decide last time?" / past plans, retros, learnings: `gbrain search "" --source gstack-brain-` -Grep is still right for known exact strings, regex, multiline patterns, and -file globs. Run `/sync-gbrain` after meaningful code changes; for ongoing -auto-sync across all worktrees, run `gbrain autopilot --install` once per -machine — gbrain's daemon handles incremental refresh on a schedule. +If live receipt verification fails, is unavailable, or does not return all +three exact markers, use repository files and `rg` instead. Run +`/sync-gbrain --code-only`; source registration may require one bootstrap +invocation and a second invocation for the expected-state sync. Re-verify +after every committed source-code or tracked-Markdown change. -Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the -orchestrator refuses destructive source ops when it detects a running autopilot -to avoid racing it (#1734). Prefer registering user repos with `gbrain sources -add --path ` (no `--url`): URL-managed sources can auto-reclone, and the -sync code walk for them requires an explicit `--allow-reclone` opt-in. +Safety: repository sync is source-scoped, uses `--no-pull`, binds the full +target HEAD plus prior bookmark, and invalidates historical GREEN evidence +before its first GBrain probe. Stored source paths must resolve to the same +canonical directory; different or ambiguous paths refuse and are never repaired +by automatic remove/re-add. Concurrent activity may produce a wrapper-lock, +source-lock, or expected-state refusal; stop and retry rather than overriding +the guard. ``` @@ -1178,13 +1264,16 @@ gbrain status: GREEN CLI ............. OK Engine .......... OK + Repository index. OK <40-char clean HEAD> (receipt trusted) Capability ...... OK write+search round-trip - CWD source ...... OK (page_count=) + CWD source ...... OK (canonical path equivalent) + Content ......... OK auto; code + tracked Markdown; image operations=0 + Search readiness. OK|WARN Call graph ...... OK edges resolved (code-callers/callees live) ~/.gstack source. OK (page_count=) — managed by /setup-gbrain - Memory sync ..... OK + Artifact sync ... OK (separate cross-machine feature) CLAUDE.md ....... OK ## GBrain Search Guidance present - Last sync ....... OK + Last receipt .... OK Run `/sync-gbrain` again any time gbrain feels off; safe and idempotent. ``` @@ -1206,6 +1295,10 @@ The **Call graph** row reports the most authoritative signal available: Any `WARN` Call graph row flips the verdict to YELLOW. +A missing/untrusted repository receipt is RED. Deferred embedding or extraction +is YELLOW (`search_ready: false`) even when repository content sync is GREEN. +Do not replace either decision with a generic page-count check. + If any row is YELLOW or RED, the verdict line says so and the failing rows surface a one-line "next action" (e.g., `Capability ...... ERR capability check failed; CLAUDE.md guidance block REMOVED — run /setup-gbrain to repair`). @@ -1215,16 +1308,19 @@ A `never`/`unknown` Call graph row flips the verdict to YELLOW. ## Concurrency note -This skill is safe to run concurrently from multiple terminals on the same -Mac. The orchestrator acquires a lock at `~/.gstack/.sync-gbrain.lock` before -any state-file or CLAUDE.md mutation and exits with code 2 if another sync is -in flight. Stale locks (process died) auto-clear after 5 minutes. +The orchestrator serializes real repository-index execution with +`~/.gstack/.sync-gbrain.lock` and exits 2 with `lock_busy` if another sync is +in flight. It never auto-breaks an existing wrapper lock because a +check-then-delete recovery can race a new live owner. Inspect the recorded PID +and recover explicitly only after proving the owner is gone. GBrain separately +owns its source lifecycle lock; never break it automatically. Wrapper +`--dry-run` acquires neither lock. ## Cross-machine note The `## GBrain Search Guidance` block is committed to the repo's CLAUDE.md and travels with `git push`/`git pull` — NOT through `~/.gstack/.brain-allowlist` -(which is for `~/.gstack/` brain-sync only). On a different Mac with a synced +(which is for cross-machine GStack artifact sync only). On a different Mac with a synced CLAUDE.md but no local gbrain, /sync-gbrain detects the mismatch via the capability check and REMOVES the block (the local agent shouldn't be told to use a tool that isn't installed). diff --git a/sync-gbrain/SKILL.md.tmpl b/sync-gbrain/SKILL.md.tmpl index aa97f7eb72..c5b98593a8 100644 --- a/sync-gbrain/SKILL.md.tmpl +++ b/sync-gbrain/SKILL.md.tmpl @@ -3,10 +3,10 @@ name: sync-gbrain preamble-tier: 2 version: 1.0.0 description: | - Keep gbrain current with this repo's code and refresh agent search + Keep gbrain current with this repo's code and tracked Markdown, and refresh agent search guidance in CLAUDE.md. Wraps the gstack-gbrain-sync orchestrator with - state probing, native code-surface registration, capability checks, - and a verdict block. Re-runnable, idempotent. Use when: "sync gbrain", + strict version/source/path preflight, expected-state sync, a bounded + receipt, capability checks, and a verdict block. Use when: "sync gbrain", "refresh gbrain", "re-index this repo", "gbrain search isn't finding things". (gstack) triggers: @@ -30,41 +30,74 @@ allowed-tools: You are running the canonical "keep this brain up to date" verb. /setup-gbrain installs gbrain once; /sync-gbrain runs every time the user wants the brain -refreshed against this repo's current state, and refreshes the agent-side +refreshed against this repo's committed code and tracked Markdown, and refreshes the agent-side guidance in CLAUDE.md so the coding agent knows when to prefer `gbrain` search over Grep. -**Architecture (post-codex review):** This skill uses gbrain v0.20.0+'s -**native code surfaces** (`gbrain sources add`, `gbrain sync --strategy code`, -`gbrain reindex-code`, `gbrain code-def/code-refs/code-callers/code-callees`). -It does NOT use `gbrain import` (that path is for markdown directories). +**Repository-index architecture:** This skill requires GBrain +`>= 0.42.71.0` and uses its expected-state repository surface +(`gbrain sync --strategy auto --no-pull --expected-target +--expected-bookmark --json`). It first requires a schema-1 +`validated_index_plan`, then applies only with that preview's exact +`--expected-plan-digest` and requires the successful apply result to echo the +same digest. It indexes admitted code plus tracked Markdown and writes a +clean-HEAD receipt only after source/bookmark, wrapper-owned marker, final +source-reread, and live worktree verification. It does NOT touch `~/.gstack/` indexing (the existing `gstack-gbrain-source-wireup` owns that — never double-store). +This repository index is distinct from **GStack artifact sync**, which moves a +curated subset of `~/.gstack/` through a private Git repository. The artifact +workflow is documented in `docs/gbrain-sync.md`; repository-index recovery is +documented in `docs/repository-index-recovery.md`. + ## User-invocable When the user types `/sync-gbrain`, run this skill. Argument modes (parsed by the skill itself, not a dispatcher binary): -- `/sync-gbrain` — incremental sync (default; mtime fast-path; ~50ms steady-state) -- `/sync-gbrain --full` — full code reindex via `gbrain reindex-code` (~25-35 min on a big repo). Auto-builds the call graph (`gbrain dream`) **only when it was never built**. +- `/sync-gbrain` — expected-state repository sync plus enabled downstream memory/artifact stages +- `/sync-gbrain --full` — repository sync plus full downstream maintenance. Auto-builds the call graph (`gbrain dream`) **only when it was never built**. - `/sync-gbrain --dream` — build this source's call graph (`gbrain code-callers`/`code-callees`) via a source-scoped `gbrain dream --source ` cycle; ~minutes; runs lock-free after the sync stages. Always forces, even if already built. Only produces a graph on a code-aware schema pack; otherwise the run reports a WARN explaining why the graph is still empty. - `/sync-gbrain --no-dream` — skip the dream cycle that `--full` would otherwise auto-run. -- `/sync-gbrain --code-only` — only run the code stage; skip memory + brain-sync -- `/sync-gbrain --dry-run` — preview what would sync; no writes anywhere +- `/sync-gbrain --code-only` — legacy flag name: run only the repository-index stage (code + tracked Markdown); skip memory + artifact sync +- `/sync-gbrain --dry-run` — `ORCHESTRATION PREVIEW — unvalidated`; no Git/engine/source/path/content probes and no writes +- `/sync-gbrain --json` — emit exactly one schema-1 `repository_index` JSON document +- `/sync-gbrain --verify-receipt` — read-only proof that the persisted GREEN receipt matches this live canonical worktree and clean full HEAD - `/sync-gbrain --no-memory` / `--no-brain-sync` — selectively skip stages - `/sync-gbrain --quiet` — suppress per-stage output - `/sync-gbrain --refresh-cache` — force-rebuild brain-aware planning cache (v1.48; replaces /brain-refresh-context per D1 fold). Skips code + memory stages; routes to `gstack-brain-cache refresh --project `. - `/sync-gbrain --audit` — emit summary of gstack-owned pages per project + sensitive-content audit (v1.48 / D10 lifecycle). Read-only. Pass-through args go straight to the orchestrator at -`{{BIN_DIR}}/gstack-gbrain-sync.ts`. +`{{BIN_DIR}}/gstack-gbrain-sync`. + +### `--dry-run` assurance short-circuit + +When `--dry-run` is present, run the orchestrator immediately and STOP this +skill after showing its result. Do not run Step 1 detection, trust prompts, +engine/source probes, capability writes, CLAUDE.md edits, health checks, +artifact sync, or dream. + +The first line must be exactly: + +```text +ORCHESTRATION PREVIEW — unvalidated +``` + +This is a no-probe wrapper preview. It proves no installed version, source, +canonical path, bookmark, or content plan, and its machine reason is +`blocked_until_version_proven` with `state_changed: none`. Never print an +unconditional raw GBrain apply command from this mode. A validated direct +content preview is allowed only after proving GBrain `>= 0.42.71.0`, the unique +source, canonical root, full HEAD, and bookmark; use the exact recovery command +in `docs/repository-index-recovery.md`. **`--refresh-cache` short-circuit:** when this flag is present, the skill runs ONLY the cache refresh (`gstack-brain-cache refresh --project ` for the current worktree's slug, plus a cross-project refresh of -user-profile if `gstack/user-profile/` exists). Code + -memory + brain-sync stages are skipped. Useful when the user knows the +user-profile if `gstack/user-profile/` exists). Repository index, +memory ingest, and artifact-sync stages are skipped. Useful when the user knows the brain has new info gstack should pick up before the next planning skill. **`--audit` short-circuit:** when this flag is present, the skill runs @@ -106,18 +139,19 @@ If `_POLICY == "unset"` AND `_HASH == "local"`, auto-set personal: ~/.claude/skills/gstack/bin/gstack-config set brain_trust_policy@$_HASH personal ``` -**Split-engine model (v1.34.0.0+).** Code stage runs locally against the +**Split-engine model (v1.34.0.0+).** Repository-index stage runs locally against the per-machine gbrain engine (PGLite or whatever `gbrain config` points to), with each worktree of a repo registered as its own source. **Memory stage also runs locally** in local-stdio MCP mode — `gstack-memory-ingest` shells out to `gbrain import` against the same local engine. In remote-http MCP mode (Path 4), the memory stage instead persists staged markdown to `~/.gstack/transcripts//` and the artifacts pipeline pushes it to -the brain admin's pull job (plan D11). Brain-sync (the `gstack-brain-sync` -push to git) is the one stage that never touches local engine and runs -regardless of mode. +the brain admin's pull job (plan D11). GStack artifact sync +(`gstack-brain-sync`, a Git push) never touches the local engine, but it does +not run past a terminal repository-index boundary unless repository indexing +was explicitly skipped. -Practically: local PGLite stays code-only on remote-http machines; the +Practically: local PGLite stays repository-index-only on remote-http machines; the remote brain holds everything else. Local-stdio machines mix code + transcripts in one local engine, as they always have. @@ -150,11 +184,10 @@ BEFORE invoking the orchestrator: symbol code search needs a local PGLite. Run `/setup-gbrain` and pick 'Yes' at the new 'local code index' prompt (Step 4.5), or run `gbrain init --pglite --json --embedding-model voyage:voyage-code-3 --embedding-dimensions 1024` - directly (drop the voyage flags if `VOYAGE_API_KEY` isn't set). Continuing - without code stage." - Then proceed to Step 2 — the orchestrator's `runCodeImport()` and - `runMemoryIngest()` will return SKIP per plan D12; only `runBrainSyncPush()` - will run. Do NOT abort. + directly (drop the voyage flags if `VOYAGE_API_KEY` isn't set)." + STOP unless the user explicitly passed `--no-code`. With `--no-code`, Step 2 + may run the non-repository stages; without it, a repository source snapshot + cannot be proven and the repository gate is terminal. - **`missing-config`** AND `gbrain_mcp_mode != "remote-http"`: STOP. "Local gbrain CLI is installed but no engine config. Run `/setup-gbrain` first." - **`broken-config`** OR **`broken-db`**: STOP with a clear message: @@ -168,13 +201,14 @@ BEFORE invoking the orchestrator: --embedding-dimensions 1024 (drop voyage flags if VOYAGE_API_KEY unset) Re-run /sync-gbrain after. ``` - Do NOT continue — the orchestrator would skip code+memory and only run - brain-sync, which is a degraded state the user should fix explicitly. + Do NOT continue — repository version/source/bookmark proof cannot complete + against a broken engine. If the user explicitly wants only Git artifact + transport, they must choose `--no-code --no-memory`. -This pre-flight short-circuits the orchestrator before it spends ~80ms -probing the engine again. The orchestrator independently runs the same -classifier for defense-in-depth, but Step 1.5's STOP is where the user -gets the actionable remediation message. +This skill-level pre-flight provides actionable remediation. The wrapper still +performs its own strict version and source snapshot checks and fails closed if +the configured engine cannot supply them; the memory stage retains its own +local-engine classifier. --- @@ -184,49 +218,91 @@ Pass user args to the orchestrator. Do not paraphrase them — pass through as-is. ```bash -bun run ~/.claude/skills/gstack/bin/gstack-gbrain-sync.ts +_GBRAIN_REPOSITORY_SYNC_BIN={{BIN_DIR}}/gstack-gbrain-sync +"$_GBRAIN_REPOSITORY_SYNC_BIN" ``` -The orchestrator runs three stages: code → memory → brain-sync (per the -plan's storage tiering). Each stage failure is non-fatal; subsequent stages -still run. State is persisted to `~/.gstack/.gbrain-sync-state.json` via -tmp-file + atomic rename. Concurrent runs are blocked by a lock file at -`~/.gstack/.sync-gbrain.lock` (5-min stale-takeover). +Record the command's exit code. If the repository stage was enabled and the +command exits nonzero, STOP on that current result and do not consult a +previously persisted receipt. If the user passed `--no-code`, report the +repository stage as explicitly skipped; do not run Step 3 and do not claim a +repository GREEN from historical evidence. + +The orchestrator starts with repository index, then memory ingest and GStack +artifact sync. A repository refusal, partial apply, or unverified apply is +terminal: unrelated later stages, attach, cleanup, reindex, and dream do not +continue across that boundary. Concurrent real runs are blocked by +`~/.gstack/.sync-gbrain.lock`. The wrapper never auto-breaks an existing +lock: inspect its recorded PID and remove it only after you have independently +proved that no owner is running. Every real repository attempt also replaces +the prior receipt with a fail-closed attempt watermark before the first GBrain +probe. A fully verified run writes its new receipt while the watermark still +blocks readers, then clears the watermark to reveal that receipt. +For an existing source, the wrapper performs a strict GBrain dry-run under the +same lock, validates its bounded schema and full `plan_digest`, then invokes +apply with `--expected-plan-digest`. Missing, malformed, changed, or +non-echoed plan evidence is terminal and cannot publish a receipt. + +If the source is absent, the first real invocation performs only safe source +registration and exits 2 with `source_registered` / +`state_changed: registry_only`. Stop and report that result; run the same +command a second time only after registration is visible. Never convert this +two-invocation bootstrap into register-and-sync. + +On a clean verified run the repository receipt is written atomically to +`~/.gstack/.gbrain-repository-index-receipt.json`. Its GREEN scope is content +sync at the recorded HEAD—not a blanket claim that embeddings, extraction, or +semantic search are ready. --- -## Step 3: Code-index health check +## Step 3: Repository receipt check -After the sync run, query gbrain for the cwd source's page_count: +Page count is descriptive and must never turn the repository verdict GREEN. +After a zero-exit current invocation with the repository stage enabled, run +the read-only live-binding verifier: ```bash -SOURCE_ID=$(grep -o '"source_id":"[^"]*"' ~/.gstack/.gbrain-sync-state.json 2>/dev/null \ - | head -1 | sed 's/.*"source_id":"//;s/".*//') -PAGES=$(gbrain sources list --json 2>/dev/null \ - | jq -r --arg id "$SOURCE_ID" '.sources[] | select(.id==$id) | .page_count' 2>/dev/null \ - || echo 0) -echo "cwd source: $SOURCE_ID, page_count: $PAGES" +_GBRAIN_REPOSITORY_SYNC_BIN={{BIN_DIR}}/gstack-gbrain-sync +"$_GBRAIN_REPOSITORY_SYNC_BIN" \ + --verify-receipt --json ``` -If `PAGES` is 0 or empty AND the user did NOT pass `--no-code` AND mode was -not `--full`, AskUserQuestion via the format in the preamble: - -> D1 — This repo has 0 indexed pages in gbrain. Run a full code reindex now? -> -> ELI10: gbrain hasn't indexed this repo's code yet. The semantic search -> tools (`gbrain search`, `code-def`, `code-refs`) will return nothing -> until we run a full pass. Takes ~25-35 minutes on a big Mac. -> -> Recommendation: A — the brain is unusable for code search until indexed, -> and Step 2 of this skill already verified gbrain is configured correctly. -> -> Note: options differ in kind, not coverage — no completeness score. -> -> A) Run /sync-gbrain --full now (recommended) -> B) Skip — I'll run it later - -If A: re-invoke the orchestrator with `--full --code-only`. -If B: continue to Step 4 with the empty-corpus state recorded. +This command contacts neither GBrain nor the engine. It re-proves the live +canonical Git root, clean full HEAD, tracked-marker state, and attached source +id against the persisted schema-1 receipt. A stale receipt from this worktree, +another worktree, or an earlier successful invocation exits 1 with +`receipt_stale`; malformed or untrusted evidence returns `receipt_invalid`; +an active wrapper lock returns `receipt_in_progress`, and a fail-closed attempt +watermark left by a newer non-GREEN run returns `receipt_superseded`. + +Proceed only when all of these are true: + +- `result_kind == "repository_index"` +- `status == "verified"` +- `state_changed == "applied_verified"` +- source path identity is `equivalent` +- full `bookmark_after == git_head` +- `last_successful_strategy == "auto"` +- `sync.plan_digest` is a full lowercase SHA-256 shared by the validated + preview and successful apply +- the recorded tree was clean +- `image_operations_applied == 0` and multimodal admission was disabled +- `verification.trusted == true` +- the verifier itself exited 0 for the current canonical root and live full HEAD + +The receipt caps its affected sample at 100, binds the full canonical +operation/path/slug/rename-source set with SHA-256, and persists the immutable +plan digest that joined preview to apply. `image_operations_applied: 0` proves +no image work in this invocation, not an image-free historical corpus. +`search_ready` and embedding/extraction states are separate: content may be +current while semantic processing is deferred. + +If the receipt is missing or any invariant fails, report the wrapper's stable +`reason_code` and `state_changed`, link +`docs/repository-index-recovery.md`, and STOP repository-success claims. +`partial` and `applied_unverified` never attach or become GREEN by inspecting +page count. --- @@ -234,7 +310,7 @@ If B: continue to Step 4 with the empty-corpus state recorded. `gbrain code-callers` / `code-callees` (who-calls-this / what-this-calls) return `count: 0` until a `gbrain dream` cycle runs the `resolve_symbol_edges` phase for -this source — not done by the code import in Step 2. +this source — not done by repository content sync in Step 2. **One hard prerequisite:** building a call graph requires this source's active **schema pack to extract code symbols** (the `extract_atoms` phase). On a pack @@ -251,8 +327,8 @@ Detect whether this source's call graph is built via doctor's `cycle_freshness` check, matching the cwd `SOURCE_ID` literally: ```bash -SOURCE_ID=$(grep -o '"source_id":"[^"]*"' ~/.gstack/.gbrain-sync-state.json 2>/dev/null \ - | head -1 | sed 's/.*"source_id":"//;s/".*//') +SOURCE_ID=$(jq -r '.evidence.source.id // empty' \ + ~/.gstack/.gbrain-repository-index-receipt.json 2>/dev/null) CYCLE=$(gbrain doctor --json --fast 2>/dev/null \ | jq -r --arg id "$SOURCE_ID" ' (.checks[] | select(.name=="cycle_freshness")) as $c @@ -266,7 +342,7 @@ echo "call graph for $SOURCE_ID: $CYCLE" ``` If `CYCLE == never` AND the user did NOT pass `--dream`/`--full` AND Step 3 -`PAGES > 0`, AskUserQuestion via the format in the preamble: +has a trusted repository receipt, AskUserQuestion via the format in the preamble: > D2 — This repo's call graph isn't built. Build it now? > @@ -286,8 +362,9 @@ If `CYCLE == never` AND the user did NOT pass `--dream`/`--full` AND Step 3 > A) Run /sync-gbrain --dream now (recommended) > B) Skip — I'll run it later -If A: re-invoke the orchestrator with `--dream --code-only` (skips memory + -brain-sync; the dream stage still runs because it's gated on `--dream`). Then +If A: re-invoke the orchestrator with `--dream --code-only` (skips memory +ingest + artifact sync; the dream stage still runs because it's gated on +`--dream`). Then report the dream stage's ACTUAL row — `OK call graph built (N edges)` vs a `WARN` that names why the graph is still empty (non-code-aware pack, missing embedding key, or 0 edges matched). Do not claim success on a WARN. @@ -344,18 +421,23 @@ Verbatim block content (copy exactly): ## GBrain Search Guidance (configured by /sync-gbrain) -GBrain is set up and synced on this machine. The agent should prefer gbrain -over Grep when the question is semantic or when you don't know the exact -identifier yet. +This checked-in block is guidance, not proof of live repository-index +freshness. Before relying on GBrain for this repository, the skill preamble +must run the host-resolved `gstack-gbrain-sync --verify-receipt --json --quiet` +launcher and observe all three exact markers: `"status":"verified"`, +`"state_changed":"applied_verified"`, and `"trusted":true`. Only then should +the agent prefer GBrain when the question is semantic or the exact identifier +is unknown. -**This worktree is pinned to a worktree-scoped code source** via the +**This worktree is pinned to a worktree-scoped repository source** via the `.gbrain-source` file in the repo root (kubectl-style context). `gbrain code-def`, `code-refs`, `code-callers`, `code-callees`, `search`, and `query` from anywhere under this worktree route to that source by default — no `--source` flag needed (gbrain >= 0.41.38.0; on older gbrain the call-graph commands need `--source "$(cat .gbrain-source)"`). Conductor sibling worktrees of the same repo each have their own pin and their own indexed pages, so -semantic results match the code on disk here. +semantic results are isolated by worktree. The pin alone never proves that +indexed content matches the current HEAD. Call-graph queries (`code-callers`/`code-callees`) also need the graph to be built first — run `/sync-gbrain --dream` (or `--full`) if they return @@ -364,7 +446,8 @@ symbols; on a non-code-aware pack `--dream` completes but the graph stays empty and reports a WARN. `code-def`/`code-refs` need the same extraction. Two indexed corpora available via the `gbrain` CLI: -- This worktree's code (auto-pinned via `.gbrain-source`). +- This worktree's admitted code and tracked Markdown (auto-pinned via + `.gbrain-source`). - `~/.gstack/` curated memory (registered as `gstack-brain-` source via the existing federation pipeline). @@ -378,16 +461,19 @@ Prefer gbrain when: - "What did we decide last time?" / past plans, retros, learnings: `gbrain search "" --source gstack-brain-` -Grep is still right for known exact strings, regex, multiline patterns, and -file globs. Run `/sync-gbrain` after meaningful code changes; for ongoing -auto-sync across all worktrees, run `gbrain autopilot --install` once per -machine — gbrain's daemon handles incremental refresh on a schedule. +If live receipt verification fails, is unavailable, or does not return all +three exact markers, use repository files and `rg` instead. Run +`/sync-gbrain --code-only`; source registration may require one bootstrap +invocation and a second invocation for the expected-state sync. Re-verify +after every committed source-code or tracked-Markdown change. -Safety: don't run `/sync-gbrain` while `gbrain autopilot` is active — the -orchestrator refuses destructive source ops when it detects a running autopilot -to avoid racing it (#1734). Prefer registering user repos with `gbrain sources -add --path ` (no `--url`): URL-managed sources can auto-reclone, and the -sync code walk for them requires an explicit `--allow-reclone` opt-in. +Safety: repository sync is source-scoped, uses `--no-pull`, binds the full +target HEAD plus prior bookmark, and invalidates historical GREEN evidence +before its first GBrain probe. Stored source paths must resolve to the same +canonical directory; different or ambiguous paths refuse and are never repaired +by automatic remove/re-add. Concurrent activity may produce a wrapper-lock, +source-lock, or expected-state refusal; stop and retry rather than overriding +the guard. ``` @@ -424,13 +510,16 @@ gbrain status: GREEN CLI ............. OK Engine .......... OK + Repository index. OK <40-char clean HEAD> (receipt trusted) Capability ...... OK write+search round-trip - CWD source ...... OK (page_count=) + CWD source ...... OK (canonical path equivalent) + Content ......... OK auto; code + tracked Markdown; image operations=0 + Search readiness. OK|WARN Call graph ...... OK edges resolved (code-callers/callees live) ~/.gstack source. OK (page_count=) — managed by /setup-gbrain - Memory sync ..... OK + Artifact sync ... OK (separate cross-machine feature) CLAUDE.md ....... OK ## GBrain Search Guidance present - Last sync ....... OK + Last receipt .... OK Run `/sync-gbrain` again any time gbrain feels off; safe and idempotent. ``` @@ -452,6 +541,10 @@ The **Call graph** row reports the most authoritative signal available: Any `WARN` Call graph row flips the verdict to YELLOW. +A missing/untrusted repository receipt is RED. Deferred embedding or extraction +is YELLOW (`search_ready: false`) even when repository content sync is GREEN. +Do not replace either decision with a generic page-count check. + If any row is YELLOW or RED, the verdict line says so and the failing rows surface a one-line "next action" (e.g., `Capability ...... ERR capability check failed; CLAUDE.md guidance block REMOVED — run /setup-gbrain to repair`). @@ -461,16 +554,19 @@ A `never`/`unknown` Call graph row flips the verdict to YELLOW. ## Concurrency note -This skill is safe to run concurrently from multiple terminals on the same -Mac. The orchestrator acquires a lock at `~/.gstack/.sync-gbrain.lock` before -any state-file or CLAUDE.md mutation and exits with code 2 if another sync is -in flight. Stale locks (process died) auto-clear after 5 minutes. +The orchestrator serializes real repository-index execution with +`~/.gstack/.sync-gbrain.lock` and exits 2 with `lock_busy` if another sync is +in flight. It never auto-breaks an existing wrapper lock because a +check-then-delete recovery can race a new live owner. Inspect the recorded PID +and recover explicitly only after proving the owner is gone. GBrain separately +owns its source lifecycle lock; never break it automatically. Wrapper +`--dry-run` acquires neither lock. ## Cross-machine note The `## GBrain Search Guidance` block is committed to the repo's CLAUDE.md and travels with `git push`/`git pull` — NOT through `~/.gstack/.brain-allowlist` -(which is for `~/.gstack/` brain-sync only). On a different Mac with a synced +(which is for cross-machine GStack artifact sync only). On a different Mac with a synced CLAUDE.md but no local gbrain, /sync-gbrain detects the mismatch via the capability check and REMOVES the block (the local agent shouldn't be told to use a tool that isn't installed). diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index eadffaa8f6..bfbfa6ff0b 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -470,33 +470,32 @@ else fi _BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync" _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=~/.claude/skills/gstack/bin/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index d99630c4b3..fcc83ad791 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -456,33 +456,32 @@ else fi _BRAIN_SYNC_BIN="$GSTACK_BIN/gstack-brain-sync" _BRAIN_CONFIG_BIN="$GSTACK_BIN/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=$GSTACK_BIN/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index a2acad24f6..042a935301 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -458,33 +458,32 @@ else fi _BRAIN_SYNC_BIN="$GSTACK_BIN/gstack-brain-sync" _BRAIN_CONFIG_BIN="$GSTACK_BIN/gstack-config" +_GBRAIN_REPOSITORY_SYNC_BIN=$GSTACK_BIN/gstack-gbrain-sync -# /sync-gbrain context-load: teach the agent to use gbrain when it's available. -# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the -# git toplevel to scope queries. Look for the pin in the worktree (not a global -# state file) so that opening worktree B without a pin doesn't claim "indexed" -# just because worktree A was synced. Empty string when gbrain is not -# configured (zero context cost for non-gbrain users). +# /sync-gbrain context-load: recommend repository search only when the bounded +# receipt still verifies against this live canonical worktree and clean HEAD. +# A .gbrain-source pin or a prior successful receipt is not sufficient. _GBRAIN_CONFIG="$HOME/.gbrain/config.json" -if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then - _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0) - if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then - _GBRAIN_PIN_PATH="" - _REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "") - if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then - _GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source" - fi - if [ -n "$_GBRAIN_PIN_PATH" ]; then - echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for" - echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for" - echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." - echo "Run /sync-gbrain to refresh." - else - echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`" - echo "before relying on \`gbrain search\` for code questions in this worktree." - echo "Falls back to Grep until pinned." +if [ -f "$_GBRAIN_CONFIG" ]; then + _GBRAIN_RECEIPT_JSON="" + _GBRAIN_RECEIPT_OK=0 + if command -v gbrain >/dev/null 2>&1 && [ -x "$_GBRAIN_REPOSITORY_SYNC_BIN" ]; then + if _GBRAIN_RECEIPT_JSON=$("$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet 2>/dev/null); then + _GBRAIN_RECEIPT_OK=1 fi fi + if [ "$_GBRAIN_RECEIPT_OK" = "1" ] && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"status":"verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"state_changed":"applied_verified"' && + printf '%s' "$_GBRAIN_RECEIPT_JSON" | grep -Fq '"trusted":true'; then + echo "GBrain receipt verified for this clean HEAD. Prefer \`gbrain search\`/\`gbrain query\`" + echo "for semantic questions and \`gbrain code-def\`/\`code-refs\`/\`code-callers\`" + echo "for symbol-aware lookup. See \"## GBrain Search Guidance\" in CLAUDE.md." + else + echo "GBrain is not receipt-verified for this worktree's current clean HEAD." + echo "Use repository files and \`rg\`; run \`/sync-gbrain --code-only\` before relying" + echo "on GBrain repository search." + fi fi _BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off) diff --git a/test/gbrain-dream-stage.test.ts b/test/gbrain-dream-stage.test.ts index d53a6568ce..44bee63eae 100644 --- a/test/gbrain-dream-stage.test.ts +++ b/test/gbrain-dream-stage.test.ts @@ -29,7 +29,7 @@ import { parseResolvedEdges, formatStage, type CliArgs, -} from "../bin/gstack-gbrain-sync"; +} from "../bin/gstack-gbrain-sync.ts"; const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-sync.ts"); @@ -44,6 +44,9 @@ function args(overrides: Partial = {}): CliArgs { codeOnly: false, dream: false, noDream: false, + allowReclone: false, + json: false, + verifyReceipt: false, ...overrides, }; } @@ -57,8 +60,20 @@ describe("shouldRunDream — gate matrix", () => { }); it("explicit --dream runs even with --code-only / --no-code (force)", () => { - expect(shouldRunDream(args({ dream: true, codeOnly: true, noMemory: true, noBrainSync: true }), null)).toBe(true); - expect(shouldRunDream(args({ dream: true, noCode: true }), null)).toBe(true); + expect( + shouldRunDream( + args({ + dream: true, + codeOnly: true, + noMemory: true, + noBrainSync: true, + }), + null, + ), + ).toBe(true); + expect(shouldRunDream(args({ dream: true, noCode: true }), null)).toBe( + true, + ); }); it("--full auto-runs ONLY when the cycle was never built", () => { @@ -69,11 +84,15 @@ describe("shouldRunDream — gate matrix", () => { }); it("--full + --no-dream never auto-runs", () => { - expect(shouldRunDream(args({ mode: "full", noDream: true }), "never")).toBe(false); + expect(shouldRunDream(args({ mode: "full", noDream: true }), "never")).toBe( + false, + ); }); it("--full + --no-code never auto-runs", () => { - expect(shouldRunDream(args({ mode: "full", noCode: true }), "never")).toBe(false); + expect(shouldRunDream(args({ mode: "full", noCode: true }), "never")).toBe( + false, + ); }); it("plain incremental never runs (no flag, no full)", () => { @@ -148,8 +167,10 @@ describe("CLI gate wiring (dry-run subprocess — never spawns a real dream)", ( return (r.stdout || "") + (r.stderr || ""); } - it("--dry-run --dream shows the dream preview row", () => { - expect(run(["--dream"])).toContain("would: gbrain dream"); + it("--dry-run --dream preserves the no-probe orchestration boundary", () => { + const output = run(["--dream"]); + expect(output).toContain("ORCHESTRATION PREVIEW — unvalidated"); + expect(output).not.toContain("would: gbrain dream"); }); it("plain --dry-run (incremental) omits the dream row", () => { @@ -196,7 +217,8 @@ describe("parseResolvedEdges", () => { }); it("does not match the bracketed [cycle.resolve_symbol_edges] marker lines", () => { // Markers have no 'resolved N' on the same line, so they must not match. - const markersOnly = "[cycle.resolve_symbol_edges] start\n[cycle.resolve_symbol_edges] done\n"; + const markersOnly = + "[cycle.resolve_symbol_edges] start\n[cycle.resolve_symbol_edges] done\n"; expect(parseResolvedEdges(markersOnly)).toBeNull(); }); }); @@ -234,7 +256,9 @@ describe("classifyDreamOutcome — post-flight truth guard", () => { describe("formatStage — WARN render", () => { const base = { name: "dream", duration_ms: 0, summary: "x" }; it("renders WARN for a ran+ok+warn stage (degraded no-op)", () => { - expect(formatStage({ ...base, ran: true, ok: true, warn: true })).toContain("WARN"); + expect(formatStage({ ...base, ran: true, ok: true, warn: true })).toContain( + "WARN", + ); }); it("renders OK for a ran+ok stage without warn", () => { const s = formatStage({ ...base, ran: true, ok: true }); @@ -242,7 +266,9 @@ describe("formatStage — WARN render", () => { expect(s).not.toContain("WARN"); }); it("renders ERR for a ran+!ok stage even if warn is set", () => { - expect(formatStage({ ...base, ran: true, ok: false, warn: true })).toContain("ERR"); + expect( + formatStage({ ...base, ran: true, ok: false, warn: true }), + ).toContain("ERR"); }); it("renders SKIP for a !ran stage", () => { expect(formatStage({ ...base, ran: false, ok: true })).toContain("SKIP"); diff --git a/test/gbrain-repository-index.test.ts b/test/gbrain-repository-index.test.ts new file mode 100644 index 0000000000..fa78554d1c --- /dev/null +++ b/test/gbrain-repository-index.test.ts @@ -0,0 +1,2708 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + mkdirSync, + lstatSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { spawnSync } from "child_process"; +import { createHash } from "crypto"; +import { + REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION, + REPOSITORY_INDEX_ATTEMPT, + REPOSITORY_INDEX_LOCK, + REPOSITORY_INDEX_RECEIPT, + classifyRepositoryPath, + compareReleasedVersions, + parseReleasedGbrainVersion, + parseStrictSourceSnapshot, + runRepositoryIndex, + summarizeAffectedItems, + unsafeRepositoryPathForShell, + verifyCurrentRepositoryIndexReceipt, + writeRepositorySourceMarker, + type GbrainSpawnOptions, + type GbrainSpawnResult, + type RepositoryIndexResult, + type RepositoryState, +} from "../lib/gbrain-repository-index"; + +const ROOT = join(import.meta.dir, ".."); +const SYNC = join(ROOT, "bin", "gstack-gbrain-sync.ts"); +const tempDirs: string[] = []; + +function makeCommittedRepo(): string { + const repo = mkdtempSync(join(tmpdir(), "gstack-repository-index-")); + tempDirs.push(repo); + expect( + spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }).status, + ).toBe(0); + expect( + spawnSync("git", ["config", "user.email", "gstack@test.invalid"], { + cwd: repo, + }).status, + ).toBe(0); + expect( + spawnSync("git", ["config", "user.name", "gstack test"], { cwd: repo }) + .status, + ).toBe(0); + writeFileSync(join(repo, "README.md"), "# fixture\n"); + expect(spawnSync("git", ["add", "README.md"], { cwd: repo }).status).toBe(0); + expect( + spawnSync("git", ["commit", "--quiet", "-m", "fixture"], { cwd: repo }) + .status, + ).toBe(0); + return repo; +} + +const ZERO_DIGEST = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; +const PLAN_DIGEST = "a".repeat(64); + +function completeChild( + sourceId: string, + head: string, + fromCommit: string | null, + status: "synced" | "first_sync" | "up_to_date" = "up_to_date", +): Record { + return { + schema_version: 1, + result_kind: "gbrain_sync", + status, + source: { id: sourceId }, + repository: { + from_commit: fromCommit, + target_commit: head, + bookmark_after: head, + last_successful_strategy: "auto", + }, + strategy: "auto", + strategy_changed: false, + operations: { added: 0, modified: 0, deleted: 0, renamed: 0 }, + affected: { + total: 0, + sample_limit: 100, + sample: [], + truncated: false, + }, + affected_digest: ZERO_DIGEST, + plan_digest: PLAN_DIGEST, + corpus: { + markdown_planned_or_applied: 0, + code_pages_before: 0, + code_pages_after: 0, + code_deletions_applied: 0, + image_operations_applied: 0, + image_pages_after: 0, + multimodal_enabled: false, + embedding_status: "deferred", + extraction_status: "deferred", + search_ready: false, + }, + }; +} + +function previewChild( + sourceId: string, + head: string, + fromCommit: string | null, + lastSuccessfulStrategy: "markdown" | "code" | "auto" | null = fromCommit === + null + ? null + : "auto", +): Record { + const child = completeChild(sourceId, head, fromCommit); + return { + ...child, + status: "dry_run", + preview_kind: "validated_index_plan", + repository: { + ...(child.repository as Record), + bookmark_after: fromCommit, + last_successful_strategy: lastSuccessfulStrategy, + }, + }; +} + +type SpawnCall = { args: string[]; options?: GbrainSpawnOptions }; + +function runnerFixture(options: { + repo: string; + sourceId?: string; + version?: string; + sources?: unknown; + postSources?: unknown; + finalSources?: unknown; + preview?: unknown; + previewExit?: number; + sync?: unknown; + syncExit?: number; + afterPostSourceProbe?: (repo: string) => void; + writeReceipt?: (path: string, receipt: RepositoryIndexResult) => void; + writeAttemptMarker?: Parameters< + typeof runRepositoryIndex + >[0]["writeAttemptMarker"]; + clearAttemptMarker?: (path: string) => void; + writeSourceMarker?: (root: string, sourceId: string) => void; + readRepositoryState?: (root: string) => RepositoryState; +}) { + const sourceId = options.sourceId ?? "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: options.repo, + encoding: "utf-8", + }).stdout.trim(); + const gstackHome = mkdtempSync(join(tmpdir(), "gstack-receipt-home-")); + tempDirs.push(gstackHome); + const calls: SpawnCall[] = []; + let sourceProbeCount = 0; + const spawnGbrain = ( + args: string[], + spawnOptions?: GbrainSpawnOptions, + ): GbrainSpawnResult => { + calls.push({ args, options: spawnOptions }); + if (args[0] === "--version") { + return { + status: 0, + stdout: + options.version ?? + `gbrain ${REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION}\n`, + }; + } + if (args.join(" ") === "sources list --json") { + sourceProbeCount++; + if (sourceProbeCount === 2) { + options.afterPostSourceProbe?.(options.repo); + } + const snapshot = + sourceProbeCount >= 3 && options.finalSources !== undefined + ? options.finalSources + : sourceProbeCount > 1 && options.postSources !== undefined + ? options.postSources + : (options.sources ?? { sources: [] }); + return { + status: 0, + stdout: JSON.stringify(snapshot), + }; + } + if (args[0] === "sources" && args[1] === "add") { + return { status: 0, stdout: "" }; + } + if (args[0] === "sync") { + if (args.includes("--dry-run")) { + const bookmarkIndex = args.indexOf("--expected-bookmark"); + const expectedBookmark = + bookmarkIndex >= 0 ? args[bookmarkIndex + 1] : head; + return { + status: options.previewExit ?? 0, + stdout: + typeof options.preview === "string" + ? options.preview + : JSON.stringify( + options.preview ?? + previewChild( + sourceId, + head, + expectedBookmark === "none" ? null : expectedBookmark, + ), + ), + }; + } + return { + status: options.syncExit ?? 0, + stdout: + typeof options.sync === "string" + ? options.sync + : JSON.stringify( + options.sync ?? + completeChild(sourceId, head, head, "up_to_date"), + ), + }; + } + throw new Error(`unexpected fake gbrain call: ${args.join(" ")}`); + }; + + return { + calls, + gstackHome, + head, + sourceId, + run: (overrides: { clean?: boolean; platform?: NodeJS.Platform } = {}) => + runRepositoryIndex({ + root: options.repo, + sourceId, + head, + workingTreeClean: overrides.clean ?? true, + gstackHome, + spawnGbrain, + platform: overrides.platform, + writeReceipt: options.writeReceipt, + writeAttemptMarker: options.writeAttemptMarker, + clearAttemptMarker: options.clearAttemptMarker, + writeSourceMarker: options.writeSourceMarker, + readRepositoryState: options.readRepositoryState, + }), + }; +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } +}); + +describe("repository-index JSON contract", () => { + test("wrapper dry-run is a no-probe, one-document orchestration preview", () => { + const repo = makeCommittedRepo(); + const home = mkdtempSync(join(tmpdir(), "gstack-repository-index-home-")); + tempDirs.push(home); + + const result = spawnSync( + process.execPath, + [SYNC, "--dry-run", "--code-only", "--json", "--quiet"], + { + cwd: repo, + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + GSTACK_HOME: join(home, ".gstack"), + PATH: "/nonexistent", + }, + }, + ); + + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed).toMatchObject({ + schema_version: 1, + result_kind: "repository_index", + status: "preview_ready", + reason_code: "blocked_until_version_proven", + state_changed: "none", + preview_kind: "orchestration_unvalidated", + }); + expect(result.stdout.trim().split("\n")).toHaveLength(1); + expect(result.stderr).toBe(""); + }); +}); + +describe("repository-index receipt rebinding", () => { + test("a verified receipt is accepted only for its live canonical clean HEAD", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: completeChild(sourceId, head, head), + }); + expect(fixture.run()).toMatchObject({ + exitCode: 0, + result: { + status: "verified", + state_changed: "applied_verified", + }, + }); + expect(existsSync(join(fixture.gstackHome, REPOSITORY_INDEX_ATTEMPT))).toBe( + false, + ); + expect( + verifyCurrentRepositoryIndexReceipt(repo, fixture.gstackHome), + ).toMatchObject({ + exitCode: 0, + result: { + status: "verified", + state_changed: "applied_verified", + }, + }); + + writeFileSync(join(repo, "README.md"), "# later commit\n"); + expect(spawnSync("git", ["add", "README.md"], { cwd: repo }).status).toBe( + 0, + ); + expect( + spawnSync("git", ["commit", "--quiet", "-m", "later"], { cwd: repo }) + .status, + ).toBe(0); + expect( + verifyCurrentRepositoryIndexReceipt(repo, fixture.gstackHome), + ).toMatchObject({ + exitCode: 1, + result: { + status: "refused", + reason_code: "receipt_stale", + state_changed: "none", + }, + }); + }); + + test("an active wrapper lock blocks an otherwise current GREEN receipt", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: completeChild(sourceId, head, head), + }); + expect(fixture.run().exitCode).toBe(0); + const lockPath = join(fixture.gstackHome, REPOSITORY_INDEX_LOCK); + writeFileSync( + lockPath, + JSON.stringify({ + pid: process.pid, + started_at: new Date().toISOString(), + }), + ); + + expect( + verifyCurrentRepositoryIndexReceipt(repo, fixture.gstackHome), + ).toMatchObject({ + exitCode: 1, + result: { + status: "refused", + reason_code: "receipt_in_progress", + state_changed: "none", + evidence: { + active_kind: "wrapper_lock", + active_path: lockPath, + }, + }, + }); + }); + + test("a persisted receipt requires the immutable preview/apply plan digest", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: completeChild(sourceId, head, head), + }); + expect(fixture.run().exitCode).toBe(0); + const receiptPath = join(fixture.gstackHome, REPOSITORY_INDEX_RECEIPT); + const receipt = JSON.parse(readFileSync(receiptPath, "utf-8")); + + for (const planDigest of [undefined, "A".repeat(64), "a".repeat(63)]) { + const invalidReceipt = structuredClone(receipt); + if (planDigest === undefined) { + delete invalidReceipt.evidence.sync.plan_digest; + } else { + invalidReceipt.evidence.sync.plan_digest = planDigest; + } + writeFileSync( + receiptPath, + `${JSON.stringify(invalidReceipt, null, 2)}\n`, + ); + expect( + verifyCurrentRepositoryIndexReceipt(repo, fixture.gstackHome), + ).toMatchObject({ + exitCode: 1, + result: { + status: "error", + reason_code: "receipt_invalid", + state_changed: "none", + }, + }); + } + }); + + test("a failed same-HEAD attempt invalidates the prior GREEN receipt", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: completeChild(sourceId, head, head), + }); + expect(fixture.run().exitCode).toBe(0); + expect(existsSync(join(fixture.gstackHome, REPOSITORY_INDEX_RECEIPT))).toBe( + true, + ); + + const failed = runRepositoryIndex({ + root: repo, + sourceId, + head, + workingTreeClean: true, + gstackHome: fixture.gstackHome, + spawnGbrain: () => ({ + status: 0, + stdout: "gbrain 0.42.70.99\n", + }), + }); + + expect(failed).toMatchObject({ + exitCode: 1, + result: { + reason_code: "unsupported_version", + state_changed: "none", + }, + }); + expect(existsSync(join(fixture.gstackHome, REPOSITORY_INDEX_RECEIPT))).toBe( + false, + ); + expect(existsSync(join(fixture.gstackHome, REPOSITORY_INDEX_ATTEMPT))).toBe( + true, + ); + expect( + verifyCurrentRepositoryIndexReceipt(repo, fixture.gstackHome), + ).toMatchObject({ + exitCode: 1, + result: { + reason_code: "receipt_superseded", + evidence: { active_kind: "attempt_watermark" }, + }, + }); + }); + + test("watermark-clear failure cannot expose the newly written receipt", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: completeChild(sourceId, head, head), + clearAttemptMarker: () => { + throw new Error("cannot clear attempt watermark"); + }, + }); + + expect(fixture.run()).toMatchObject({ + exitCode: 1, + result: { + reason_code: "verification_failed", + state_changed: "applied_unverified", + evidence: { failing_step: "attempt_watermark_clear" }, + }, + }); + expect(existsSync(join(fixture.gstackHome, REPOSITORY_INDEX_RECEIPT))).toBe( + true, + ); + expect( + verifyCurrentRepositoryIndexReceipt(repo, fixture.gstackHome), + ).toMatchObject({ + exitCode: 1, + result: { + reason_code: "receipt_superseded", + evidence: { active_kind: "attempt_watermark" }, + }, + }); + }); + + test("a receipt from another canonical worktree cannot verify", () => { + const repo = makeCommittedRepo(); + const otherRepo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: completeChild(sourceId, head, head), + }); + expect(fixture.run().exitCode).toBe(0); + + expect( + verifyCurrentRepositoryIndexReceipt(otherRepo, fixture.gstackHome), + ).toMatchObject({ + exitCode: 1, + result: { + status: "refused", + reason_code: "receipt_stale", + state_changed: "none", + evidence: { + receipt_canonical_path: realpathSync.native(repo), + live_canonical_path: realpathSync.native(otherRepo), + }, + }, + }); + }); +}); + +describe("repository-index safety helpers", () => { + test("skill and recovery docs consume only a live-bound receipt with exact corpus fields", () => { + for (const relative of [ + "sync-gbrain/SKILL.md.tmpl", + "sync-gbrain/SKILL.md", + "docs/repository-index-recovery.md", + ]) { + const text = readFileSync(join(ROOT, relative), "utf-8"); + expect(text).toContain("image_operations_applied"); + expect(text).not.toMatch(/`image_operations(?:\s*==|:)/); + } + for (const relative of [ + "sync-gbrain/SKILL.md.tmpl", + "sync-gbrain/SKILL.md", + ]) { + const text = readFileSync(join(ROOT, relative), "utf-8"); + expect(text).toContain("--verify-receipt --json"); + expect(text).toContain( + "command exits nonzero, STOP on that current result", + ); + expect(text).toMatch( + /do not claim a\s+repository GREEN from historical evidence/, + ); + } + }); + + test("global guidance and setup require the safe live-receipt wrapper", () => { + const preamble = readFileSync( + join(ROOT, "scripts/resolvers/preamble/generate-brain-sync-block.ts"), + "utf-8", + ); + expect(preamble).toContain( + '"$_GBRAIN_REPOSITORY_SYNC_BIN" --verify-receipt --json --quiet', + ); + expect(preamble).toContain('_GBRAIN_RECEIPT_OK" = "1"'); + expect(preamble).toContain(`'"status":"verified"'`); + expect(preamble).toContain(`'"state_changed":"applied_verified"'`); + expect(preamble).toContain(`'"trusted":true'`); + expect(preamble).toContain("Use repository files and"); + expect(preamble).not.toContain("_GBRAIN_PIN_PATH"); + + const setup = readFileSync( + join(ROOT, "setup-gbrain", "SKILL.md.tmpl"), + "utf-8", + ); + expect(setup).toContain( + '"$_GBRAIN_REPOSITORY_SYNC_BIN" --code-only --json', + ); + expect(setup).toContain( + "Do **not** create or refresh `## GBrain Search Guidance`", + ); + expect(setup).not.toContain('gbrain import "$(pwd)" --no-embed'); + expect(setup).not.toContain("gbrain embed --stale &"); + + const syncSkill = readFileSync( + join(ROOT, "sync-gbrain", "SKILL.md.tmpl"), + "utf-8", + ); + expect(syncSkill).toContain( + "This checked-in block is guidance, not proof of live repository-index", + ); + expect(syncSkill).toContain("The pin alone never proves that"); + expect(syncSkill).toContain("indexed content matches the current HEAD."); + expect(syncSkill).toContain( + "If live receipt verification fails, is unavailable, or does not return all", + ); + expect(syncSkill).toContain("--expected-plan-digest"); + expect(syncSkill).toContain("sync.plan_digest"); + expect(syncSkill).not.toContain( + "the brain auto-syncs incrementally on every gstack skill start", + ); + + const recovery = readFileSync( + join(ROOT, "docs", "repository-index-recovery.md"), + "utf-8", + ); + expect(recovery).toContain('--source "$SOURCE_ID"'); + expect(recovery).toContain('--repo "$REPOSITORY_ROOT"'); + expect(recovery).toContain( + '--expected-plan-digest "$_GBRAIN_REPAIR_PLAN_DIGEST"', + ); + expect(recovery).toContain('value.preview_kind !== "validated_index_plan"'); + expect(recovery).not.toContain("remove only `--dry-run`"); + expect(recovery).not.toContain("--source "); + expect(recovery).not.toContain("--repo "); + }); + + test("released-version parsing is strict and compares all four fields", () => { + expect(parseReleasedGbrainVersion("gbrain 0.42.71.0\n")).toEqual([ + 0, 42, 71, 0, + ]); + expect(parseReleasedGbrainVersion("0.42.71.1")).toEqual([0, 42, 71, 1]); + for (const invalid of [ + "0.42.71", + "v0.42.71.0", + "gbrain 0.42.71.0-beta.1", + "gbrain 0.42.71.0+build", + "version 0.42.71.0", + "gbrain 0.42.71.0 extra", + ]) { + expect(parseReleasedGbrainVersion(invalid)).toBeNull(); + } + expect(compareReleasedVersions([0, 42, 70, 99], [0, 42, 71, 0])).toBe(-1); + expect(compareReleasedVersions([0, 42, 71, 0], [0, 42, 71, 0])).toBe(0); + expect(compareReleasedVersions([0, 42, 71, 1], [0, 42, 71, 0])).toBe(1); + }); + + test("strict source snapshots accept only complete unique bare/wrapped rows", () => { + const row = { + id: "source-a", + local_path: ".", + last_commit: "a".repeat(40), + last_successful_strategy: "auto", + }; + const bare = parseStrictSourceSnapshot([row]); + expect(bare.ok).toBe(true); + if (bare.ok) { + expect(bare.rows).toEqual([row]); + expect(Object.isFrozen(bare.rows)).toBe(true); + expect(Object.isFrozen(bare.rows[0])).toBe(true); + } + expect(parseStrictSourceSnapshot({ sources: [row] }).ok).toBe(true); + const withUnrelatedNullPath = parseStrictSourceSnapshot({ + sources: [ + { + id: "default", + local_path: null, + last_commit: null, + last_successful_strategy: null, + }, + row, + ], + }); + expect(withUnrelatedNullPath.ok).toBe(true); + + for (const invalid of [ + null, + {}, + { sources: [], extra: true }, + { sources: [null] }, + { sources: [{ id: "source-a" }] }, + { sources: [{ id: "source-a", local_path: 7 }] }, + { + sources: [ + row, + { + id: "source-a", + local_path: "/other", + last_commit: null, + last_successful_strategy: null, + }, + ], + }, + { + sources: [ + { + id: "source-a", + local_path: ".", + last_commit: "not-a-sha", + last_successful_strategy: null, + }, + ], + }, + { + sources: [ + { + id: "source-a", + local_path: ".", + last_commit: null, + last_successful_strategy: "future-strategy", + }, + ], + }, + ]) { + expect(parseStrictSourceSnapshot(invalid).ok).toBe(false); + } + }); + + test("path identity recognizes relative, absolute, and symlink aliases", () => { + const repo = makeCommittedRepo(); + const parent = mkdtempSync(join(tmpdir(), "gstack-path-identity-")); + tempDirs.push(parent); + const alias = join(parent, "repo-alias"); + const other = join(parent, "other"); + const file = join(parent, "not-a-directory"); + symlinkSync(repo, alias, "dir"); + mkdirSync(other); + writeFileSync(file, "not a directory"); + + expect(classifyRepositoryPath(".", repo).kind).toBe("equivalent"); + expect(classifyRepositoryPath(repo, repo).kind).toBe("equivalent"); + expect(classifyRepositoryPath(alias, repo).kind).toBe("equivalent"); + expect(classifyRepositoryPath(other, repo).kind).toBe("different"); + expect(classifyRepositoryPath(file, repo).kind).toBe("ambiguous"); + expect(classifyRepositoryPath(join(parent, "missing"), repo).kind).toBe( + "ambiguous", + ); + }); + + test("Windows shell transport refuses metacharacters without changing POSIX", () => { + expect(unsafeRepositoryPathForShell("C:\\safe-repo", "win32")).toBe(false); + for (const unsafe of [ + " ", + "\t", + "&", + "|", + "^", + "%", + "!", + "(", + ")", + '"', + "\r", + "\n", + ]) { + expect( + unsafeRepositoryPathForShell(`C:\\repo${unsafe}sentinel`, "win32"), + ).toBe(true); + } + expect( + unsafeRepositoryPathForShell("/tmp/repo&ordinary-on-posix", "darwin"), + ).toBe(false); + }); + + test.skipIf(process.platform === "win32")( + "wrapper-owned marker replacement never follows a raced symlink", + () => { + const repo = makeCommittedRepo(); + const outside = mkdtempSync(join(tmpdir(), "gstack-marker-target-")); + tempDirs.push(outside); + const target = join(outside, "sentinel"); + writeFileSync(target, "do not overwrite\n"); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: completeChild(sourceId, head, head), + afterPostSourceProbe: (root) => + symlinkSync(target, join(root, ".gbrain-source")), + }); + + expect(fixture.run()).toMatchObject({ + exitCode: 0, + result: { + status: "verified", + state_changed: "applied_verified", + }, + }); + expect(readFileSync(target, "utf-8")).toBe("do not overwrite\n"); + expect(lstatSync(join(repo, ".gbrain-source")).isSymbolicLink()).toBe( + false, + ); + expect(readFileSync(join(repo, ".gbrain-source"), "utf-8")).toBe( + `${sourceId}\n`, + ); + }, + ); + + test("wrapper-owned marker attachment fails closed on a directory target", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: completeChild(sourceId, head, head), + afterPostSourceProbe: (root) => mkdirSync(join(root, ".gbrain-source")), + }); + + expect(fixture.run()).toMatchObject({ + exitCode: 1, + result: { + reason_code: "verification_failed", + state_changed: "applied_unverified", + evidence: { failing_step: "source_attach" }, + }, + }); + expect(lstatSync(join(repo, ".gbrain-source")).isDirectory()).toBe(true); + }); + + test("affected evidence is canonical, stable, and capped at 100 rows", () => { + const items = Array.from({ length: 101 }, (_, index) => ({ + operation: "modify", + path: `docs/${String(index).padStart(3, "0")}.md`, + slug: `doc-${index}`, + })); + const forward = summarizeAffectedItems(items); + const reverse = summarizeAffectedItems([...items].reverse()); + expect(forward.total).toBe(101); + expect(forward.sample).toHaveLength(100); + expect(forward.truncated).toBe(true); + expect(forward.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(reverse).toEqual(forward); + const changedOutsideSample = summarizeAffectedItems([ + ...items.slice(0, 100), + { ...items[100], slug: "changed-outside-sample" }, + ]); + expect(changedOutsideSample.sample).toEqual(forward.sample); + expect(changedOutsideSample.sha256).not.toBe(forward.sha256); + }); + + test("affected digest matches the literal schema-1 byte contract", () => { + const summary = summarizeAffectedItems([ + { + operation: "rename", + path: "docs/new.md", + slug: "new", + from_path: "docs/old.md", + }, + { + operation: "add", + path: "CLAUDE.md", + slug: "claude", + }, + ]); + + // SHA-256( + // "add\tCLAUDE.md\tclaude\t\n" + + // "rename\tdocs/new.md\tnew\tdocs/old.md\n", + // ). + expect(summary.sha256).toBe( + "ff9653905409b935202cd05330f1aadf714a0ce6fe159a00fdc1c55e01c5cdae", + ); + }); + + test("affected evidence sorts by UTF-8 bytes, not UTF-16 code units", () => { + const supplementary = "docs/\u{10000}.md"; + const privateUse = "docs/\uE000.md"; + const summary = summarizeAffectedItems([ + { operation: "modify", path: supplementary, slug: "supplementary" }, + { operation: "modify", path: privateUse, slug: "private-use" }, + ]); + + expect(summary.sample.map((item) => item.path)).toEqual([ + privateUse, + supplementary, + ]); + }); + + test("affected evidence rejects past-tense operation aliases", () => { + for (const operation of ["added", "modified", "deleted", "renamed"]) { + expect(() => + summarizeAffectedItems([ + { operation, path: "docs/example.md", slug: "example" }, + ]), + ).toThrow(/canonical repository-relative paths/); + } + }); +}); + +describe("repository-index orchestration", () => { + test("old versions refuse before the source snapshot", () => { + const repo = makeCommittedRepo(); + const fixture = runnerFixture({ repo, version: "gbrain 0.42.70.99" }); + const output = fixture.run(); + + expect(output.exitCode).toBe(1); + expect(output.result).toMatchObject({ + status: "refused", + reason_code: "unsupported_version", + state_changed: "none", + }); + expect(fixture.calls.map((call) => call.args)).toEqual([["--version"]]); + }); + + test("an absent source is registered once and stops at registry_only", () => { + const repo = makeCommittedRepo(); + const fixture = runnerFixture({ repo, sources: { sources: [] } }); + const output = fixture.run(); + + expect(output.exitCode).toBe(2); + expect(output.result).toMatchObject({ + status: "incomplete", + reason_code: "source_registered", + state_changed: "registry_only", + }); + expect(fixture.calls.map((call) => call.args)).toEqual([ + ["--version"], + ["sources", "list", "--json"], + [ + "sources", + "add", + fixture.sourceId, + "--path", + realpathSync.native(repo), + "--federated", + ], + ]); + }); + + test("an absent id never registers over an equivalent path owned by another source", () => { + const repo = makeCommittedRepo(); + const fixture = runnerFixture({ + repo, + sources: { + sources: [ + { + id: "already-indexed-here", + local_path: repo, + last_commit: null, + last_successful_strategy: null, + }, + ], + }, + }); + const output = fixture.run(); + + expect(output).toMatchObject({ + exitCode: 1, + result: { + status: "refused", + reason_code: "source_path_ambiguous", + state_changed: "none", + }, + }); + expect(fixture.calls.map((call) => call.args)).toEqual([ + ["--version"], + ["sources", "list", "--json"], + ]); + }); + + test("an existing id still refuses ambiguous duplicate path ownership", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const fixture = runnerFixture({ + repo, + sourceId, + sources: { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + { + id: "duplicate-owner", + local_path: ".", + last_commit: null, + last_successful_strategy: null, + }, + ], + }, + }); + + expect(fixture.run()).toMatchObject({ + exitCode: 1, + result: { + status: "refused", + reason_code: "source_path_ambiguous", + state_changed: "none", + }, + }); + expect(fixture.calls.map((call) => call.args)).toEqual([ + ["--version"], + ["sources", "list", "--json"], + ]); + }); + + test("equivalent source runs exact expected-state argv/env and writes receipt", () => { + const repo = makeCommittedRepo(); + const receipts: RepositoryIndexResult[] = []; + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const fixture = runnerFixture({ + repo, + sourceId, + sources: { + sources: [ + { + id: sourceId, + local_path: ".", + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }, + sync: completeChild(sourceId, head, head), + writeReceipt: (_path, receipt) => receipts.push(receipt), + }); + const inherited = process.env.GBRAIN_EMBEDDING_MULTIMODAL; + const output = fixture.run(); + + expect(output.exitCode).toBe(0); + expect(output.result).toMatchObject({ + status: "verified", + reason_code: "up_to_date", + state_changed: "applied_verified", + }); + expect(receipts).toHaveLength(1); + expect(receipts[0]).toMatchObject({ + evidence: { + sync: { + plan_digest: PLAN_DIGEST, + }, + }, + }); + expect(fixture.calls.map((call) => call.args)).toEqual([ + ["--version"], + ["sources", "list", "--json"], + [ + "sync", + "--strategy", + "auto", + "--source", + sourceId, + "--repo", + realpathSync.native(repo), + "--no-pull", + "--expected-target", + head, + "--expected-bookmark", + head, + "--require-clean", + "--dry-run", + "--json", + ], + [ + "sync", + "--strategy", + "auto", + "--source", + sourceId, + "--repo", + realpathSync.native(repo), + "--no-pull", + "--expected-target", + head, + "--expected-bookmark", + head, + "--require-clean", + "--expected-plan-digest", + PLAN_DIGEST, + "--json", + ], + ["sources", "list", "--json"], + ["sources", "list", "--json"], + ]); + expect(fixture.calls[2].options?.baseEnv?.GBRAIN_EMBEDDING_MULTIMODAL).toBe( + "false", + ); + for (const call of fixture.calls) { + expect(call.options?.baseEnv?.GBRAIN_EMBEDDING_MULTIMODAL).toBe("false"); + } + expect(process.env.GBRAIN_EMBEDDING_MULTIMODAL).toBe(inherited); + expect(readFileSync(join(repo, ".gbrain-source"), "utf-8")).toBe( + `${sourceId}\n`, + ); + expect( + spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { + cwd: repo, + encoding: "utf-8", + }).stdout, + ).toBe(""); + expect( + spawnSync("git", ["ls-files"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(), + ).toBe("README.md"); + const excludePath = spawnSync( + "git", + ["rev-parse", "--git-path", "info/exclude"], + { cwd: repo, encoding: "utf-8" }, + ).stdout.trim(); + expect(readFileSync(join(repo, excludePath), "utf-8")).toContain( + ".gbrain-source", + ); + }); + + test("a null bookmark is passed as none for first_sync", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const initialSource = { + id: sourceId, + local_path: repo, + last_commit: null, + last_successful_strategy: null, + }; + const completedSource = { + ...initialSource, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [initialSource] }, + postSources: { sources: [completedSource] }, + sync: completeChild(sourceId, head, null, "first_sync"), + }); + + expect(fixture.run()).toMatchObject({ + exitCode: 0, + result: { + status: "verified", + state_changed: "applied_verified", + }, + }); + const syncCalls = fixture.calls + .filter((call) => call.args[0] === "sync") + .map((call) => call.args); + const expectedState = [ + "sync", + "--strategy", + "auto", + "--source", + sourceId, + "--repo", + realpathSync.native(repo), + "--no-pull", + "--expected-target", + head, + "--expected-bookmark", + "none", + "--require-clean", + ]; + expect(syncCalls).toEqual([ + [...expectedState, "--dry-run", "--json"], + [...expectedState, "--expected-plan-digest", PLAN_DIGEST, "--json"], + ]); + }); + + test("preview preserves the source's prior successful strategy", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const initialSource = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "code", + }; + const completedSource = { + ...initialSource, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [initialSource] }, + postSources: { sources: [completedSource] }, + preview: previewChild(sourceId, head, head, "code"), + sync: completeChild(sourceId, head, head), + }); + + expect(fixture.run()).toMatchObject({ + exitCode: 0, + result: { + status: "verified", + state_changed: "applied_verified", + }, + }); + }); + + test("dirty input omits require-clean but can never produce GREEN", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + writeFileSync(join(repo, "dirty.md"), "uncommitted\n"); + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: completeChild(sourceId, head, head), + }); + + expect(fixture.run({ clean: false })).toMatchObject({ + exitCode: 1, + result: { + status: "error", + reason_code: "verification_failed", + state_changed: "applied_unverified", + }, + }); + const syncArgs = fixture.calls.find( + (call) => call.args[0] === "sync", + )?.args; + expect(syncArgs).not.toContain("--require-clean"); + expect(syncArgs?.at(-1)).toBe("--json"); + }); + + test("a matching untracked source marker is locally excluded before retry", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + writeFileSync(join(repo, ".gbrain-source"), `${sourceId}\n`); + chmodSync(join(repo, ".gbrain-source"), 0o644); + expect( + spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { + cwd: repo, + encoding: "utf-8", + }).stdout, + ).toContain(".gbrain-source"); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: completeChild(sourceId, head, head), + }); + + expect(fixture.run({ clean: false })).toMatchObject({ + exitCode: 0, + result: { + status: "verified", + state_changed: "applied_verified", + }, + }); + const syncArgs = fixture.calls.find( + (call) => call.args[0] === "sync", + )?.args; + expect(syncArgs).toContain("--require-clean"); + expect( + spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { + cwd: repo, + encoding: "utf-8", + }).stdout, + ).toBe(""); + }); + + test("a durable pre-sync exclude change cannot be reported as state none", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + writeFileSync(join(repo, ".gbrain-source"), `${sourceId}\n`); + chmodSync(join(repo, ".gbrain-source"), 0o644); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const refusedChild = { + schema_version: 1, + result_kind: "gbrain_sync_error", + status: "refused", + reason_code: "source_changed", + state_changed: "none", + problem: "source changed", + observed: null, + required: "stable source", + next_action: "retry", + }; + + const changed = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: refusedChild, + syncExit: 1, + }).run({ clean: false }); + expect(changed).toMatchObject({ + exitCode: 1, + result: { + reason_code: "source_changed", + state_changed: "partial", + }, + }); + + const excludeRelative = spawnSync( + "git", + ["rev-parse", "--git-path", "info/exclude"], + { cwd: repo, encoding: "utf-8" }, + ).stdout.trim(); + const excludePath = join(repo, excludeRelative); + expect(readFileSync(excludePath, "utf-8")).toContain(".gbrain-source"); + + const unchanged = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: refusedChild, + syncExit: 1, + }).run({ clean: false }); + expect(unchanged).toMatchObject({ + exitCode: 1, + result: { + reason_code: "source_changed", + state_changed: "none", + }, + }); + }); + + test.skipIf(process.platform === "win32")( + "a group-writable 0660 source marker is refused before sync", + () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + writeFileSync(join(repo, ".gbrain-source"), `${sourceId}\n`); + chmodSync(join(repo, ".gbrain-source"), 0o660); + const fixture = runnerFixture({ + repo, + sourceId, + sources: { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }, + sync: completeChild(sourceId, head, head), + }); + + expect(fixture.run({ clean: false })).toMatchObject({ + exitCode: 1, + result: { + status: "refused", + reason_code: "verification_failed", + state_changed: "none", + evidence: { + failing_step: "pre_sync_source_marker", + attached_source: { + present: true, + trustworthy: false, + detail: ".gbrain-source is group- or world-writable", + }, + }, + }, + }); + expect(fixture.calls.some((call) => call.args[0] === "sync")).toBe(false); + }, + ); + + test.skipIf(process.platform === "win32")( + "a symlinked source marker is refused without following its target", + () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const markerTarget = join(repo, ".gbrain-source-target"); + writeFileSync(markerTarget, `${sourceId}\n`); + chmodSync(markerTarget, 0o644); + symlinkSync(markerTarget, join(repo, ".gbrain-source")); + const fixture = runnerFixture({ + repo, + sourceId, + sources: { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }, + sync: completeChild(sourceId, head, head), + }); + + expect(fixture.run({ clean: false })).toMatchObject({ + exitCode: 1, + result: { + status: "refused", + reason_code: "verification_failed", + state_changed: "none", + evidence: { + failing_step: "pre_sync_source_marker", + attached_source: { + present: true, + trustworthy: false, + }, + }, + }, + }); + expect(readFileSync(markerTarget, "utf-8")).toBe(`${sourceId}\n`); + expect(fixture.calls.some((call) => call.args[0] === "sync")).toBe(false); + }, + ); + + test.skipIf(process.platform === "win32")( + "the local git exclude writer refuses a symlink without changing its target", + () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + writeFileSync(join(repo, ".gbrain-source"), `${sourceId}\n`); + chmodSync(join(repo, ".gbrain-source"), 0o644); + const rawExcludePath = spawnSync( + "git", + ["rev-parse", "--git-path", "info/exclude"], + { cwd: repo, encoding: "utf-8" }, + ).stdout.trim(); + const excludePath = join(repo, rawExcludePath); + const excludeTarget = join(repo, "exclude-target"); + writeFileSync(excludeTarget, "sentinel\n"); + rmSync(excludePath); + symlinkSync(excludeTarget, excludePath); + const fixture = runnerFixture({ + repo, + sourceId, + sources: { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }, + sync: completeChild(sourceId, head, head), + }); + + expect(fixture.run({ clean: false })).toMatchObject({ + exitCode: 1, + result: { + status: "error", + reason_code: "verification_failed", + state_changed: "none", + evidence: { + failing_step: "source_marker_exclude", + }, + }, + }); + expect(lstatSync(excludePath).isSymbolicLink()).toBe(true); + expect(readFileSync(excludeTarget, "utf-8")).toBe("sentinel\n"); + expect(fixture.calls.some((call) => call.args[0] === "sync")).toBe(false); + }, + ); + + test("a tracked source marker can never produce a trusted receipt", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + writeFileSync(join(repo, ".gbrain-source"), `${sourceId}\n`); + expect( + spawnSync("git", ["add", ".gbrain-source"], { cwd: repo }).status, + ).toBe(0); + expect( + spawnSync("git", ["commit", "--quiet", "-m", "track unsafe marker"], { + cwd: repo, + }).status, + ).toBe(0); + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: completeChild(sourceId, head, head), + }); + + expect(fixture.run()).toMatchObject({ + exitCode: 1, + result: { + status: "refused", + reason_code: "repository_state_invalid", + state_changed: "none", + }, + }); + expect(fixture.calls.some((call) => call.args[0] === "sync")).toBe(false); + }); + + test.each(["tracked", "untracked"] as const)( + "an oversized %s source marker is rejected before sync", + (kind) => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + writeFileSync(join(repo, ".gbrain-source"), `${"a".repeat(35)}\n`); + chmodSync(join(repo, ".gbrain-source"), 0o644); + if (kind === "tracked") { + expect( + spawnSync("git", ["add", ".gbrain-source"], { cwd: repo }).status, + ).toBe(0); + expect( + spawnSync( + "git", + ["commit", "--quiet", "-m", "track oversized marker"], + { + cwd: repo, + }, + ).status, + ).toBe(0); + } + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const fixture = runnerFixture({ + repo, + sourceId, + sources: { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }, + sync: completeChild(sourceId, head, head), + }); + + expect(fixture.run({ clean: kind === "tracked" })).toMatchObject({ + exitCode: 1, + result: { + status: "refused", + reason_code: "verification_failed", + state_changed: "none", + evidence: { + attached_source: { + trustworthy: false, + detail: ".gbrain-source exceeds 34 bytes", + }, + }, + }, + }); + expect(fixture.calls.some((call) => call.args[0] === "sync")).toBe(false); + }, + ); + + test("partial and malformed child results never attach", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const sources = { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }; + const partial = runnerFixture({ + repo, + sourceId, + sources, + sync: { + schema_version: 1, + result_kind: "gbrain_sync", + status: "partial", + }, + }); + expect(partial.run().result).toMatchObject({ + reason_code: "sync_partial", + state_changed: "partial", + }); + expect(partial.calls.some((call) => call.args[1] === "attach")).toBe(false); + + const malformed = runnerFixture({ + repo, + sourceId, + sources, + sync: '{"schema_version":1}\ntrailing', + }); + expect(malformed.run().result).toMatchObject({ + reason_code: "source_result_invalid", + state_changed: "applied_unverified", + }); + expect(malformed.calls.some((call) => call.args[1] === "attach")).toBe( + false, + ); + }); + + test("preview plan evidence is mandatory before the apply process starts", () => { + for (const planDigest of [undefined, "A".repeat(64), "a".repeat(63)]) { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const preview = previewChild(sourceId, head, head); + if (planDigest === undefined) { + delete preview.plan_digest; + } else { + preview.plan_digest = planDigest; + } + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + preview, + }); + + expect(fixture.run()).toMatchObject({ + exitCode: 1, + result: { + status: "error", + reason_code: "source_result_invalid", + state_changed: "none", + evidence: { + failing_step: "sync_preview", + }, + }, + }); + const syncCalls = fixture.calls.filter((call) => call.args[0] === "sync"); + expect(syncCalls).toHaveLength(1); + expect(syncCalls[0].args).toContain("--dry-run"); + } + }); + + test("apply success must echo the exact validated preview plan digest", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + + const missing = completeChild(sourceId, head, head); + delete missing.plan_digest; + expect( + runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: missing, + }).run(), + ).toMatchObject({ + exitCode: 1, + result: { + status: "error", + reason_code: "source_result_invalid", + state_changed: "applied_unverified", + }, + }); + + const mismatch = completeChild(sourceId, head, head); + mismatch.plan_digest = "b".repeat(64); + expect( + runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + sync: mismatch, + }).run(), + ).toMatchObject({ + exitCode: 1, + result: { + status: "error", + reason_code: "source_result_invalid", + state_changed: "applied_unverified", + evidence: { + failing_step: "sync_plan_binding", + preview_plan_digest: PLAN_DIGEST, + apply_plan_digest: "b".repeat(64), + }, + }, + }); + }); + + test("up_to_date with mutation evidence can never verify", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const sources = { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }; + const child = completeChild(sourceId, head, head, "up_to_date"); + const item = { + operation: "add", + path: "src/new.ts", + slug: "src/new", + }; + child.operations = { added: 1, modified: 0, deleted: 0, renamed: 0 }; + child.affected = { + total: 1, + sample_limit: 100, + sample: [item], + truncated: false, + }; + child.affected_digest = createHash("sha256") + .update("add\tsrc/new.ts\tsrc/new\t\n") + .digest("hex"); + + const fixture = runnerFixture({ + repo, + sourceId, + sources, + sync: child, + }); + expect(fixture.run()).toMatchObject({ + exitCode: 1, + result: { + status: "error", + reason_code: "source_result_invalid", + state_changed: "applied_unverified", + }, + }); + expect(fixture.calls.some((call) => call.args[1] === "attach")).toBe(false); + }); + + test("incomplete, nullable, or contradictory corpus receipts never verify", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const sources = { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }; + const valid = completeChild(sourceId, head, head); + const validCorpus = { + ...((valid.corpus as Record) ?? {}), + }; + const invalidCorpora: Record[] = [ + { + ...validCorpus, + code_pages_after: null, + }, + { + ...validCorpus, + embedding_status: "unknown", + }, + { + ...validCorpus, + search_ready: true, + }, + { + ...validCorpus, + embedding_status: "complete", + extraction_status: "complete", + search_ready: false, + }, + { + ...validCorpus, + unexpected_future_field: 1, + }, + Object.fromEntries( + Object.entries(validCorpus).filter( + ([key]) => key !== "markdown_planned_or_applied", + ), + ), + ]; + + for (const corpus of invalidCorpora) { + const child = { ...valid, corpus }; + const fixture = runnerFixture({ + repo, + sourceId, + sources, + sync: child, + }); + expect(fixture.run()).toMatchObject({ + exitCode: 1, + result: { + status: "error", + reason_code: "source_result_invalid", + state_changed: "applied_unverified", + }, + }); + expect(fixture.calls.some((call) => call.args[1] === "attach")).toBe( + false, + ); + } + }); + + test("a complete semantic receipt may claim search readiness", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const sources = { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }; + const child = completeChild(sourceId, head, head); + child.corpus = { + ...(child.corpus as Record), + embedding_status: "complete", + extraction_status: "complete", + search_ready: true, + }; + + expect( + runnerFixture({ repo, sourceId, sources, sync: child }).run(), + ).toMatchObject({ + exitCode: 0, + result: { + status: "verified", + state_changed: "applied_verified", + evidence: { + corpus: { + embedding_status: "complete", + extraction_status: "complete", + search_ready: true, + }, + }, + }, + }); + }); + + test("complete affected samples bind their digest and operation shape", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const sources = { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }; + const childWithItem = ( + item: Record, + digest = createHash("sha256") + .update( + `${item.operation}\t${item.path}\t${item.slug}\t${item.from_path ?? ""}\n`, + ) + .digest("hex"), + ) => { + const child = completeChild(sourceId, head, head, "synced"); + const operation = item.operation; + child.operations = { + added: operation === "add" ? 1 : 0, + modified: operation === "modify" ? 1 : 0, + deleted: operation === "delete" ? 1 : 0, + renamed: operation === "rename" ? 1 : 0, + }; + child.affected = { + total: 1, + sample_limit: 100, + sample: [item], + truncated: false, + }; + child.affected_digest = digest; + return child; + }; + + const validItem = { + operation: "add", + path: "docs/example.md", + slug: "example", + }; + expect( + runnerFixture({ + repo, + sourceId, + sources, + sync: childWithItem(validItem), + }).run(), + ).toMatchObject({ + exitCode: 0, + result: { status: "verified", state_changed: "applied_verified" }, + }); + + const validRename = { + operation: "rename", + path: "docs/new.md", + slug: "new", + from_path: "docs/old.md", + }; + expect( + runnerFixture({ + repo, + sourceId, + sources, + sync: childWithItem(validRename), + }).run(), + ).toMatchObject({ + exitCode: 0, + result: { + status: "verified", + state_changed: "applied_verified", + evidence: { + sync: { + affected: { + sample: [validRename], + sha256: + "bf7e42b6ba6d622321e588846d4f8ad7654cbec3493b0487e378a69afd15a2c2", + }, + }, + }, + }, + }); + + const invalidChildren = [ + childWithItem(validItem, "0".repeat(64)), + { + ...childWithItem({ + operation: "delete", + path: "docs/example.md", + slug: "example", + }), + operations: { added: 1, modified: 0, deleted: 0, renamed: 0 }, + }, + childWithItem({ + operation: "rename", + path: "docs/new.md", + slug: "new", + }), + childWithItem({ + operation: "rename", + path: "docs/new.md", + slug: "new", + from_path: 7, + }), + childWithItem({ + operation: "add", + path: "docs/example.md", + slug: "example", + from_path: "docs/old.md", + }), + childWithItem( + { + operation: "add", + path: "docs\\example.md", + slug: "example", + }, + createHash("sha256") + .update("add\tdocs/example.md\texample\t\n") + .digest("hex"), + ), + childWithItem({ + operation: "modify", + path: "docs/\0example.md", + slug: "example", + }), + childWithItem({ + operation: "delete", + path: "docs/example.md", + slug: "example\u001b", + }), + childWithItem({ + operation: "rename", + path: "docs/new.md", + slug: "new", + from_path: "docs/old\u007f.md", + }), + ]; + + for (const sync of invalidChildren) { + const fixture = runnerFixture({ repo, sourceId, sources, sync }); + expect(fixture.run()).toMatchObject({ + exitCode: 1, + result: { + reason_code: "source_result_invalid", + state_changed: "applied_unverified", + }, + }); + expect(fixture.calls.some((call) => call.args[1] === "attach")).toBe( + false, + ); + } + }); + + test("attach and receipt failures remain applied_unverified", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const sources = { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }; + const child = completeChild(sourceId, head, head); + + const attachFailure = runnerFixture({ + repo, + sourceId, + sources, + sync: child, + writeSourceMarker: () => { + throw new Error("source marker is not writable"); + }, + }).run(); + expect(attachFailure.result).toMatchObject({ + reason_code: "verification_failed", + state_changed: "applied_unverified", + evidence: { failing_step: "source_attach" }, + }); + + const receiptFixture = runnerFixture({ + repo, + sourceId, + sources, + sync: child, + writeReceipt: () => { + throw new Error("read-only receipt directory"); + }, + }); + const receiptFailure = receiptFixture.run(); + expect(receiptFailure.result).toMatchObject({ + reason_code: "verification_failed", + state_changed: "applied_unverified", + evidence: { failing_step: "receipt_write" }, + }); + expect( + existsSync(join(receiptFixture.gstackHome, REPOSITORY_INDEX_ATTEMPT)), + ).toBe(true); + expect( + existsSync(join(receiptFixture.gstackHome, REPOSITORY_INDEX_RECEIPT)), + ).toBe(false); + expect( + verifyCurrentRepositoryIndexReceipt(repo, receiptFixture.gstackHome), + ).toMatchObject({ + exitCode: 1, + result: { + reason_code: "receipt_superseded", + evidence: { active_kind: "attempt_watermark" }, + }, + }); + }); + + test("a post-sync source with no path fails verification before attach", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + postSources: { + sources: [{ ...source, local_path: null }], + }, + sync: completeChild(sourceId, head, head), + }); + + expect(fixture.run()).toMatchObject({ + exitCode: 1, + result: { + reason_code: "verification_failed", + state_changed: "applied_unverified", + evidence: { failing_step: "post_sync_source_snapshot" }, + }, + }); + expect(fixture.calls.some((call) => call.args[1] === "attach")).toBe(false); + }); + + test("duplicate path ownership appearing after sync blocks attach", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + postSources: { + sources: [ + source, + { + id: "late-duplicate-owner", + local_path: ".", + last_commit: null, + last_successful_strategy: null, + }, + ], + }, + sync: completeChild(sourceId, head, head), + }); + + expect(fixture.run()).toMatchObject({ + exitCode: 1, + result: { + reason_code: "verification_failed", + state_changed: "applied_unverified", + evidence: { failing_step: "post_sync_source_snapshot" }, + }, + }); + expect(fixture.calls.some((call) => call.args[1] === "attach")).toBe(false); + }); + + test("a source rebind after marker attachment blocks the trusted receipt", () => { + const repo = makeCommittedRepo(); + const other = mkdtempSync(join(tmpdir(), "gstack-final-source-other-")); + tempDirs.push(other); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const source = { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { sources: [source] }, + postSources: { sources: [source] }, + finalSources: { + sources: [{ ...source, local_path: other }], + }, + sync: completeChild(sourceId, head, head), + }); + + expect(fixture.run()).toMatchObject({ + exitCode: 1, + result: { + reason_code: "verification_failed", + state_changed: "applied_unverified", + evidence: { failing_step: "post_attach_source_snapshot" }, + }, + }); + expect( + fixture.calls.filter( + (call) => call.args.join(" ") === "sources list --json", + ), + ).toHaveLength(3); + }); + + test("post-attach pin or Git drift prevents a trusted receipt", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const sources = { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }; + const child = completeChild(sourceId, head, head); + + const wrongPin = runnerFixture({ + repo, + sourceId, + sources, + sync: child, + writeSourceMarker: (root) => + writeRepositorySourceMarker(root, "different-source"), + }).run(); + expect(wrongPin.result).toMatchObject({ + reason_code: "verification_failed", + state_changed: "applied_unverified", + evidence: { failing_step: "post_attach_repository_state" }, + }); + writeFileSync(join(repo, ".gbrain-source"), `${sourceId}\n`); + chmodSync(join(repo, ".gbrain-source"), 0o644); + + const drift = runnerFixture({ + repo, + sourceId, + sources, + sync: child, + writeSourceMarker: (root, id) => { + writeRepositorySourceMarker(root, id); + writeFileSync(join(root, "drift.md"), "changed\n"); + }, + }).run(); + expect(drift.result).toMatchObject({ + reason_code: "verification_failed", + state_changed: "applied_unverified", + evidence: { failing_step: "post_attach_repository_state" }, + }); + rmSync(join(repo, "drift.md")); + + const untrustedPin = runnerFixture({ + repo, + sourceId, + sources, + sync: child, + writeSourceMarker: (root, id) => { + writeRepositorySourceMarker(root, id); + chmodSync(join(root, ".gbrain-source"), 0o666); + }, + }).run(); + expect(untrustedPin.result).toMatchObject({ + reason_code: "verification_failed", + state_changed: "applied_unverified", + evidence: { failing_step: "post_attach_repository_state" }, + }); + chmodSync(join(repo, ".gbrain-source"), 0o644); + + const unstableHead = runnerFixture({ + repo, + sourceId, + sources, + sync: child, + readRepositoryState: () => ({ + head, + head_after: "b".repeat(40), + stable: false, + clean: true, + porcelain: "", + source_marker_tracked: false, + }), + }).run(); + expect(unstableHead.result).toMatchObject({ + reason_code: "verification_failed", + state_changed: "applied_unverified", + evidence: { failing_step: "post_attach_repository_state" }, + }); + }); + + test("typed GBrain refusals preserve reason, state, and retry exit", () => { + const repo = makeCommittedRepo(); + const sourceId = "gstack-code-fixture-12345678"; + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const sources = { + sources: [ + { + id: sourceId, + local_path: repo, + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }; + const childError = ( + reasonCode: string, + stateChanged: "none" | "lock_only" | "partial", + status: "refused" | "error" = "refused", + ) => ({ + schema_version: 1, + result_kind: "gbrain_sync_error", + status, + reason_code: reasonCode, + state_changed: stateChanged, + problem: reasonCode, + observed: reasonCode === "plan_changed" ? "b".repeat(64) : null, + required: + reasonCode === "plan_changed" ? PLAN_DIGEST : "safe preconditions", + next_action: "retry", + }); + + for (const reasonCode of [ + "source_changed", + "target_changed", + "bookmark_changed", + "working_tree_dirty", + "managed_clone_missing", + "plan_changed", + "dry_run_modifier_conflict", + "embedding_credentials_missing", + "cost_gate_stopped", + ]) { + const refused = runnerFixture({ + repo, + sourceId, + sources, + sync: childError(reasonCode, "none"), + syncExit: 1, + }).run(); + expect(refused).toMatchObject({ + exitCode: 1, + result: { + status: "refused", + reason_code: reasonCode, + state_changed: "none", + ...(reasonCode === "plan_changed" + ? { + evidence: { + child_observed: "b".repeat(64), + child_required: PLAN_DIGEST, + }, + } + : {}), + }, + }); + } + + const lockBusy = runnerFixture({ + repo, + sourceId, + sources, + sync: childError("lock_busy", "none"), + syncExit: 1, + }).run(); + expect(lockBusy).toMatchObject({ + exitCode: 2, + result: { + status: "incomplete", + reason_code: "lock_busy", + state_changed: "none", + }, + }); + + const releaseFailed = runnerFixture({ + repo, + sourceId, + sources, + sync: childError("lock_release_failed", "lock_only", "error"), + syncExit: 1, + }).run(); + expect(releaseFailed).toMatchObject({ + exitCode: 1, + result: { + status: "error", + reason_code: "lock_release_failed", + state_changed: "lock_only", + }, + }); + + for (const [status, stateChanged] of [ + ["refused", "none"], + ["error", "none"], + ["error", "partial"], + ] as const) { + const planFailed = runnerFixture({ + repo, + sourceId, + sources, + sync: childError("plan_failed", stateChanged, status), + syncExit: 1, + }).run(); + expect(planFailed).toMatchObject({ + exitCode: 1, + result: { + status, + reason_code: "plan_failed", + state_changed: stateChanged, + }, + }); + } + + for (const invalid of [ + childError("source_changed", "partial", "error"), + childError("lock_release_failed", "none", "refused"), + childError("plan_failed", "partial", "refused"), + ]) { + expect( + runnerFixture({ + repo, + sourceId, + sources, + sync: invalid, + syncExit: 1, + }).run(), + ).toMatchObject({ + exitCode: 1, + result: { + status: "error", + reason_code: "source_result_invalid", + state_changed: "partial", + }, + }); + } + }); + + test("different or ambiguous source paths never remove and re-add", () => { + const repo = makeCommittedRepo(); + const other = mkdtempSync(join(tmpdir(), "gstack-other-source-")); + tempDirs.push(other); + const sourceId = "gstack-code-fixture-12345678"; + const fixture = runnerFixture({ + repo, + sourceId, + sources: { + sources: [ + { + id: sourceId, + local_path: other, + last_commit: null, + last_successful_strategy: null, + }, + ], + }, + }); + const output = fixture.run(); + + expect(output.result).toMatchObject({ + reason_code: "source_path_different", + state_changed: "none", + }); + expect(fixture.calls.map((call) => call.args)).toEqual([ + ["--version"], + ["sources", "list", "--json"], + ]); + + const nullPath = runnerFixture({ + repo, + sourceId, + sources: { + sources: [ + { + id: sourceId, + local_path: null, + last_commit: null, + last_successful_strategy: null, + }, + ], + }, + }); + expect(nullPath.run().result).toMatchObject({ + reason_code: "source_path_ambiguous", + state_changed: "none", + }); + expect(nullPath.calls.map((call) => call.args)).toEqual([ + ["--version"], + ["sources", "list", "--json"], + ]); + }); + + test("Windows safety is rechecked after canonicalizing a safe alias", () => { + const parent = mkdtempSync(join(tmpdir(), "gstack-canonical-shell-")); + tempDirs.push(parent); + const target = join(parent, "bad&target"); + const alias = join(parent, "safe-alias"); + mkdirSync(target); + expect( + spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: target }) + .status, + ).toBe(0); + expect( + spawnSync("git", ["config", "user.email", "gstack@test.invalid"], { + cwd: target, + }).status, + ).toBe(0); + expect( + spawnSync("git", ["config", "user.name", "gstack test"], { + cwd: target, + }).status, + ).toBe(0); + writeFileSync(join(target, "README.md"), "# fixture\n"); + expect(spawnSync("git", ["add", "README.md"], { cwd: target }).status).toBe( + 0, + ); + expect( + spawnSync("git", ["commit", "--quiet", "-m", "fixture"], { + cwd: target, + }).status, + ).toBe(0); + symlinkSync(target, alias, "dir"); + const sourceId = "gstack-code-fixture-12345678"; + const fixture = runnerFixture({ + repo: alias, + sourceId, + sources: { + sources: [ + { + id: sourceId, + local_path: alias, + last_commit: null, + last_successful_strategy: null, + }, + ], + }, + }); + + expect(fixture.run({ platform: "win32" })).toMatchObject({ + exitCode: 1, + result: { + status: "refused", + reason_code: "unsupported_path", + state_changed: "none", + }, + }); + expect(fixture.calls.map((call) => call.args)).toEqual([ + ["--version"], + ["sources", "list", "--json"], + ]); + + const absent = runnerFixture({ + repo: alias, + sourceId, + sources: { sources: [] }, + }); + expect(absent.run({ platform: "win32" })).toMatchObject({ + exitCode: 1, + result: { + status: "refused", + reason_code: "unsupported_path", + state_changed: "none", + }, + }); + expect(absent.calls.map((call) => call.args)).toEqual([ + ["--version"], + ["sources", "list", "--json"], + ]); + }); +}); diff --git a/test/gbrain-source-gitignore.test.ts b/test/gbrain-source-gitignore.test.ts index 1fd1db05e7..01ae45dbf7 100644 --- a/test/gbrain-source-gitignore.test.ts +++ b/test/gbrain-source-gitignore.test.ts @@ -9,11 +9,19 @@ */ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, chmodSync, statSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, + chmodSync, + statSync, +} from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { ensureGbrainSourceGitignored } from "../bin/gstack-gbrain-sync"; +import { ensureGbrainSourceGitignored } from "../bin/gstack-gbrain-sync.ts"; describe("ensureGbrainSourceGitignored", () => { let root: string; diff --git a/test/gbrain-sync-skip.test.ts b/test/gbrain-sync-skip.test.ts index 902256b3bc..202e247c19 100644 --- a/test/gbrain-sync-skip.test.ts +++ b/test/gbrain-sync-skip.test.ts @@ -1,31 +1,27 @@ /** - * Tests the split-engine SKIP semantics in bin/gstack-gbrain-sync.ts (plan D12). + * Tests the split-engine boundaries in bin/gstack-gbrain-sync.ts. * - * When localEngineStatus() returns anything except 'ok', the orchestrator's - * code + memory stages return ran=false summaries; the brain-sync stage runs - * unchanged. This is the behavior that matters most for Garry's broken-db - * machine — instead of crashing two stages with ERR output, the orchestrator - * surfaces a clear skip reason and still pushes artifacts. + * The repository stage now proves the released GBrain version and one strict + * source snapshot directly, so version/source probe failures are terminal and + * later stages do not run. Memory and dream keep the older local-engine SKIP + * behavior because they are independent downstream stages. * - * We test via the script (spawn) rather than importing runCodeImport/runMemoryIngest - * directly because they're internal to the orchestrator. The fake gbrain - * binary controls localEngineStatus()'s output. + * We test via the script (spawn) because the stage runners are internal to the + * orchestrator. The fake gbrain binary controls both the strict repository + * probe and localEngineStatus() used by downstream stages. */ import { describe, it, expect } from "bun:test"; -import { - mkdtempSync, - mkdirSync, - writeFileSync, - chmodSync, - rmSync, -} from "fs"; +import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { execFileSync, spawnSync } from "child_process"; +import { REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION } from "../lib/gbrain-repository-index"; const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-sync.ts"); -const BUN_BIN = execFileSync("sh", ["-c", "command -v bun"], { encoding: "utf-8" }).trim(); +const BUN_BIN = execFileSync("sh", ["-c", "command -v bun"], { + encoding: "utf-8", +}).trim(); interface FakeEnv { tmp: string; @@ -37,12 +33,13 @@ interface FakeEnv { /** * Build a sandboxed HOME with optional fake gbrain on PATH. - * `gbrainBehavior` controls how `gbrain sources list` reacts; this drives - * localEngineStatus()'s output. + * `gbrainBehavior` controls how `gbrain sources list` reacts; this drives both + * the strict repository source probe and localEngineStatus(). */ function makeEnv(opts: { withGbrain: boolean; - gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "engine-locked" | "slow"; + gbrainBehavior?: + "ok" | "broken-db" | "broken-config" | "engine-locked" | "slow"; withConfig: boolean; }): FakeEnv { const tmp = mkdtempSync(join(tmpdir(), "gbrain-sync-skip-")); @@ -78,14 +75,14 @@ function makeEnv(opts: { : behavior === "engine-locked" ? ` echo "gbrain sources: connect timed out (default 10000ms; pass --timeout=Ns to override)." >&2 exit 124` - : ` ${ - behavior === "broken-db" - ? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2' - : 'echo "Error: malformed config.json" >&2' - } + : ` ${ + behavior === "broken-db" + ? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2' + : 'echo "Error: malformed config.json" >&2' + } exit 1`; const fake = `#!/bin/sh -if [ "$1" = "--version" ]; then echo "gbrain 0.33.1.0"; exit 0; fi +if [ "$1" = "--version" ]; then echo "gbrain ${REQUIRED_GBRAIN_REPOSITORY_INDEX_VERSION}"; exit 0; fi if [ "$1 $2" = "sources list" ]; then ${sourcesBlock} fi @@ -113,10 +110,20 @@ function runOrchestrator( // Initialize a git repo in the sandbox so repoRoot() finds it (otherwise // code stage skips with "not in git repo" before our check ever fires). spawnSync("git", ["init", "-q", env.home], { encoding: "utf-8" }); - spawnSync("git", ["-C", env.home, "commit", "--allow-empty", "-m", "init", "-q"], { - encoding: "utf-8", - env: { ...process.env, GIT_AUTHOR_NAME: "T", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "T", GIT_COMMITTER_EMAIL: "t@t" }, - }); + spawnSync( + "git", + ["-C", env.home, "commit", "--allow-empty", "-m", "init", "-q"], + { + encoding: "utf-8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "T", + GIT_AUTHOR_EMAIL: "t@t", + GIT_COMMITTER_NAME: "T", + GIT_COMMITTER_EMAIL: "t@t", + }, + }, + ); const result = spawnSync(BUN_BIN, [SCRIPT, ...args], { encoding: "utf-8", @@ -137,26 +144,35 @@ function runOrchestrator( }; } -describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => { - it("PROCEEDS (with warning) when the engine probe times out — slow is not broken (#1964)", () => { - const env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true }); +describe("gstack-gbrain-sync — strict repository and downstream SKIP boundaries", () => { + it("repository stage uses its strict source probe instead of the local-engine timeout shortcut", () => { + const env = makeEnv({ + withGbrain: true, + gbrainBehavior: "slow", + withConfig: true, + }); try { const r = runOrchestrator(env, ["--code-only"], { GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "300", }); const out = r.stdout + r.stderr; - // The stage must NOT be skipped with the local-engine reason... + expect(r.exitCode).toBe(2); expect(out).not.toContain("local engine timeout"); expect(out).not.toContain("config.json is malformed"); - // ...and the proceed-with-warning line must name the env knob. - expect(out).toContain("GSTACK_GBRAIN_PROBE_TIMEOUT_MS"); + expect(out).not.toContain("GSTACK_GBRAIN_PROBE_TIMEOUT_MS"); + expect(out).toContain("ERROR [source_registered]"); + expect(out).toContain("State changed: registry_only"); } finally { env.cleanup(); } - }, 30_000); // proceeding runs the real code-import path against the slow fake (~11s) + }, 30_000); it("memory stage also PROCEEDS (with warning) on probe timeout (#1964)", () => { - const env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true }); + const env = makeEnv({ + withGbrain: true, + gbrainBehavior: "slow", + withConfig: true, + }); try { const r = runOrchestrator(env, ["--no-code", "--no-brain-sync"], { GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "300", @@ -170,7 +186,11 @@ describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => { }, 30_000); it("dream stage also PROCEEDS (with warning) on probe timeout (#1964)", () => { - const env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true }); + const env = makeEnv({ + withGbrain: true, + gbrainBehavior: "slow", + withConfig: true, + }); try { const r = runOrchestrator( env, @@ -185,23 +205,31 @@ describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => { } }, 30_000); - it("SKIPs code stage when local engine is broken-db; brain-sync still attempted", () => { - const env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-db", withConfig: true }); + it("repository source-probe failure is terminal when the engine is broken-db", () => { + const env = makeEnv({ + withGbrain: true, + gbrainBehavior: "broken-db", + withConfig: true, + }); try { const r = runOrchestrator(env, ["--code-only"]); - // Code stage should be SKIPped with a clear local-engine status reason. - // Match on the summary substring our skipStageForLocalStatus helper emits. - expect(r.stdout + r.stderr).toContain("local engine broken-db"); - // Crucial: NOT the legacy "source registration failed" error path that - // existed before this fix (codex #2 STOP-vs-SKIP consistency). - expect(r.stdout + r.stderr).not.toContain("source registration failed"); + const out = r.stdout + r.stderr; + expect(r.exitCode).toBe(1); + expect(out).toContain("ERROR [source_probe_failed]"); + expect(out).toContain("Cannot connect to database"); + expect(out).toContain("State changed: none"); + expect(out).not.toContain("local engine broken-db"); } finally { env.cleanup(); } }); it("SKIPs memory stage when local engine is broken-config", () => { - const env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-config", withConfig: true }); + const env = makeEnv({ + withGbrain: true, + gbrainBehavior: "broken-config", + withConfig: true, + }); try { const r = runOrchestrator(env, ["--no-code", "--no-brain-sync"]); expect(r.stdout + r.stderr).toContain("local engine broken-config"); @@ -210,56 +238,72 @@ describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => { } }); - it("SKIPs with actionable guidance when PGLite is held by gbrain serve (#2194)", () => { - const env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true }); + it("repository source-probe timeout fails closed when PGLite is held (#2194)", () => { + const env = makeEnv({ + withGbrain: true, + gbrainBehavior: "engine-locked", + withConfig: true, + }); try { const r = runOrchestrator(env, ["--code-only"]); const out = r.stdout + r.stderr; - expect(out).toContain("local engine engine-locked"); - expect(out).toContain("gbrain serve"); - expect(out).toContain("outside the live Claude session"); + expect(r.exitCode).toBe(1); + expect(out).toContain("ERROR [source_probe_failed]"); + expect(out).toContain("connect timed out"); + expect(out).toContain("State changed: none"); + expect(out).not.toContain("local engine engine-locked"); expect(out).not.toContain("config.json is malformed"); } finally { env.cleanup(); } }); - it("SKIPs code stage when gbrain CLI is missing (no-cli)", () => { + it("repository stage refuses before source mutation when gbrain CLI is missing", () => { const env = makeEnv({ withGbrain: false, withConfig: false }); try { const r = runOrchestrator(env, ["--code-only"]); - // Either "no-cli" (from skipStageForLocalStatus) OR the earlier - // gbrainAvailable() check (which fires first when the CLI is absent — - // returns "skipped (gbrain CLI not in PATH)"). Both are acceptable for - // this case; the user-visible outcome is the same. const out = r.stdout + r.stderr; - const hasSkipReason = - out.includes("no-cli") || out.includes("gbrain CLI not in PATH"); - expect(hasSkipReason).toBe(true); + expect(r.exitCode).toBe(1); + expect(out).toContain("ERROR [unsupported_version]"); + expect(out).toContain("State changed: none"); + expect(out).toContain("Next command: gbrain --version"); + expect(out).not.toContain("local engine no-cli"); } finally { env.cleanup(); } }); - it("SKIPs code stage when config is missing (missing-config)", () => { - const env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false }); + it("repository stage trusts successful direct probes even when config is absent", () => { + const env = makeEnv({ + withGbrain: true, + gbrainBehavior: "ok", + withConfig: false, + }); try { const r = runOrchestrator(env, ["--code-only"]); - expect(r.stdout + r.stderr).toContain("local engine missing-config"); + const out = r.stdout + r.stderr; + expect(r.exitCode).toBe(2); + expect(out).toContain("ERROR [source_registered]"); + expect(out).toContain("State changed: registry_only"); + expect(out).not.toContain("local engine missing-config"); } finally { env.cleanup(); } }); - it("runs code stage normally when local engine is ok", () => { - const env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true }); + it("registers an absent source once when direct repository probes succeed", () => { + const env = makeEnv({ + withGbrain: true, + gbrainBehavior: "ok", + withConfig: true, + }); try { const r = runOrchestrator(env, ["--code-only"]); - // When ok, the SKIP-for-local-status branch must NOT fire. - expect(r.stdout + r.stderr).not.toContain("local engine ok"); - expect(r.stdout + r.stderr).not.toContain("local engine no-cli"); - expect(r.stdout + r.stderr).not.toContain("local engine broken-db"); - expect(r.stdout + r.stderr).not.toContain("local engine missing-config"); + const out = r.stdout + r.stderr; + expect(r.exitCode).toBe(2); + expect(out).toContain("ERROR [source_registered]"); + expect(out).toContain("State changed: registry_only"); + expect(out).not.toContain("local engine ok"); } finally { env.cleanup(); } diff --git a/test/gstack-gbrain-sync.test.ts b/test/gstack-gbrain-sync.test.ts index 19a9bac4eb..46a9702fee 100644 --- a/test/gstack-gbrain-sync.test.ts +++ b/test/gstack-gbrain-sync.test.ts @@ -8,29 +8,46 @@ */ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, chmodSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, + mkdirSync, + chmodSync, + statSync, + utimesSync, +} from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { spawnSync } from "child_process"; import { + deriveCodeSourceId, derivePathOnlyHashLegacyId, planHostnameFoldMigration, sourceLocalPath, _resetGbrainSupportsRenameCache, -} from "../bin/gstack-gbrain-sync"; +} from "../bin/gstack-gbrain-sync.ts"; const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-sync.ts"); +const LAUNCHER = join(import.meta.dir, "..", "bin", "gstack-gbrain-sync"); function makeTestHome(): string { return mkdtempSync(join(tmpdir(), "gstack-gbrain-sync-")); } -function runScript(args: string[], env: Record = {}): { stdout: string; stderr: string; exitCode: number } { +function runScript( + args: string[], + env: Record = {}, + cwd?: string, +): { stdout: string; stderr: string; exitCode: number } { const result = spawnSync("bun", [SCRIPT, ...args], { encoding: "utf-8", timeout: 60000, env: { ...process.env, ...env }, + cwd, }); return { stdout: result.stdout || "", @@ -39,6 +56,21 @@ function runScript(args: string[], env: Record = {}): { stdout: }; } +function sourceIdFor(repo: string, hostname?: string): string { + const cwd = process.cwd(); + const previousHostname = process.env.GSTACK_HOSTNAME; + try { + process.chdir(repo); + if (hostname === undefined) delete process.env.GSTACK_HOSTNAME; + else process.env.GSTACK_HOSTNAME = hostname; + return deriveCodeSourceId(repo); + } finally { + process.chdir(cwd); + if (previousHostname === undefined) delete process.env.GSTACK_HOSTNAME; + else process.env.GSTACK_HOSTNAME = previousHostname; + } +} + describe("gstack-gbrain-sync CLI", () => { it("--help exits 0 with usage text", () => { const r = runScript(["--help"]); @@ -49,79 +81,208 @@ describe("gstack-gbrain-sync CLI", () => { expect(r.stderr).toContain("--dry-run"); }); + it("ships an executable launcher that forwards to the TypeScript CLI", () => { + expect(statSync(LAUNCHER).mode & 0o111).not.toBe(0); + const result = spawnSync( + LAUNCHER, + ["--dry-run", "--code-only", "--json", "--quiet"], + { + encoding: "utf-8", + env: process.env, + }, + ); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + result_kind: "repository_index", + status: "preview_ready", + reason_code: "blocked_until_version_proven", + }); + }); + it("rejects unknown flag", () => { const r = runScript(["--bogus"]); expect(r.exitCode).toBe(1); expect(r.stderr).toContain("Unknown argument: --bogus"); }); + it("--json reports unknown arguments as one document in either order", () => { + for (const args of [ + ["--json", "--bogus"], + ["--bogus", "--json"], + ]) { + const r = runScript(args); + expect(r.exitCode).toBe(1); + expect(r.stderr).toBe(""); + expect(r.stdout.trim().split("\n")).toHaveLength(1); + expect(JSON.parse(r.stdout)).toMatchObject({ + schema_version: 1, + result_kind: "repository_index", + status: "error", + reason_code: "invalid_arguments", + state_changed: "none", + }); + } + }); + + it("conflicting modes refuse before probe or lock in either order", () => { + for (const args of [ + ["--dry-run", "--full"], + ["--full", "--dry-run"], + ]) { + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + const r = runScript([...args, "--quiet"], { + HOME: home, + GSTACK_HOME: gstackHome, + }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain("Conflicting modes:"); + expect(existsSync(join(gstackHome, ".sync-gbrain.lock"))).toBe(false); + rmSync(home, { recursive: true, force: true }); + } + }); + + it("conflicting modes preserve the one-document JSON contract", () => { + for (const args of [ + ["--json", "--dry-run", "--full"], + ["--full", "--dry-run", "--json"], + ]) { + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + const r = runScript(args, { + HOME: home, + GSTACK_HOME: gstackHome, + }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toBe(""); + expect(r.stdout.trim().split("\n")).toHaveLength(1); + expect(JSON.parse(r.stdout)).toMatchObject({ + result_kind: "repository_index", + reason_code: "invalid_arguments", + state_changed: "none", + }); + expect(existsSync(join(gstackHome, ".sync-gbrain.lock"))).toBe(false); + rmSync(home, { recursive: true, force: true }); + } + }); + it("uses the shared local gbrain status classifier instead of shelling through command -v", () => { const source = readFileSync(SCRIPT, "utf-8"); - expect(source).not.toContain('command -v gbrain'); + expect(source).not.toContain("command -v gbrain"); expect(source).toContain("localEngineStatus"); }); - it("--dry-run with --code-only reports the code import preview only", () => { + it("--dry-run with --code-only reports an unvalidated no-probe preview", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); mkdirSync(gstackHome, { recursive: true }); - const r = runScript(["--dry-run", "--code-only", "--quiet"], { HOME: home, GSTACK_HOME: gstackHome }); + const r = runScript(["--dry-run", "--code-only", "--quiet"], { + HOME: home, + GSTACK_HOME: gstackHome, + }); expect(r.exitCode).toBe(0); - // Code stage now uses native code surface: sources add + sync --strategy code - // (NOT gbrain import — that's the markdown-only path that was rejected post-codex). - expect(r.stdout).toContain("would: gbrain sources add"); - expect(r.stdout).toContain("gbrain sync --strategy code"); - expect(r.stdout).not.toContain("gbrain import"); - // memory + brain-sync stages should not appear - expect(r.stdout).not.toContain("gstack-memory-ingest --probe"); - expect(r.stdout).not.toContain("gstack-brain-sync --discover-new"); + expect(r.stdout).toContain("ORCHESTRATION PREVIEW — unvalidated"); + expect(r.stdout).toContain("GBrain was not contacted."); + expect(r.stdout).toContain("blocked_until_version_proven"); + expect(r.stdout).not.toContain("gbrain sync --strategy"); rmSync(home, { recursive: true, force: true }); }); - it("--dry-run with all stages shows previews for all three", () => { + it("--dry-run never claims stage-level actions were validated", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); mkdirSync(gstackHome, { recursive: true }); const r = runScript(["--dry-run"], { HOME: home, GSTACK_HOME: gstackHome }); expect(r.exitCode).toBe(0); - expect(r.stdout).toContain("would: gbrain sources add"); - expect(r.stdout).toContain("gbrain sync --strategy code"); - expect(r.stdout).toContain("would: gstack-memory-ingest"); - expect(r.stdout).toContain("would: gstack-brain-sync"); + expect(r.stdout).toContain("ORCHESTRATION PREVIEW — unvalidated"); + expect(r.stdout).not.toContain("would: gbrain sources add"); + expect(r.stdout).not.toContain("would: gstack-memory-ingest"); + expect(r.stdout).not.toContain("would: gstack-brain-sync"); rmSync(home, { recursive: true, force: true }); }); - it("--no-code skips the code import stage", () => { + it("--no-code does not turn dry-run into a probed stage preview", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); mkdirSync(gstackHome, { recursive: true }); - const r = runScript(["--dry-run", "--no-code"], { HOME: home, GSTACK_HOME: gstackHome }); + const r = runScript(["--dry-run", "--no-code"], { + HOME: home, + GSTACK_HOME: gstackHome, + }); expect(r.exitCode).toBe(0); - expect(r.stdout).not.toContain("would: gbrain sources add"); - expect(r.stdout).toContain("would: gstack-memory-ingest"); + expect(r.stdout).toContain("ORCHESTRATION PREVIEW — unvalidated"); + expect(r.stdout).not.toContain("would:"); rmSync(home, { recursive: true, force: true }); }); - it("dry-run derives a stable source id from the canonical git remote", () => { - // The source id pattern is `gstack-code-`. For this - // repo (github.com/garrytan/gstack), the slug should appear in the dry-run - // preview line. We don't pin the exact slug — just verify the prefix + - // that the preview command would target a source with id gstack-code-*. + it("--verify-receipt is read-only, one-document, and never contacts gbrain", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); + const binDir = join(home, "bin"); + const invocationLog = join(home, "gbrain-invocations.log"); + const repo = mkdtempSync(join(tmpdir(), "gstack-receipt-cli-")); mkdirSync(gstackHome, { recursive: true }); + mkdirSync(binDir); + expect( + spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }).status, + ).toBe(0); + const fakeGbrain = join(binDir, "gbrain"); + writeFileSync( + fakeGbrain, + `#!/bin/sh +printf '%s\\n' "$*" >> "$GSTACK_TEST_GBRAIN_LOG" +exit 99 +`, + ); + chmodSync(fakeGbrain, 0o755); + + const result = runScript( + ["--verify-receipt", "--json", "--quiet"], + { + HOME: home, + GSTACK_HOME: gstackHome, + GSTACK_TEST_GBRAIN_LOG: invocationLog, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + }, + repo, + ); - const r = runScript(["--dry-run", "--code-only", "--quiet"], { HOME: home, GSTACK_HOME: gstackHome }); - expect(r.exitCode).toBe(0); - expect(r.stdout).toMatch(/gbrain sources add gstack-code-[a-z0-9-]+/); - expect(r.stdout).toMatch(/gbrain sync --strategy code --source gstack-code-[a-z0-9-]+/); + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(""); + expect(result.stdout.trim().split("\n")).toHaveLength(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + result_kind: "repository_index", + reason_code: "receipt_missing", + state_changed: "none", + }); + expect(existsSync(invocationLog)).toBe(false); + expect(existsSync(join(gstackHome, ".sync-gbrain.lock"))).toBe(false); + rmSync(repo, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); }); + it("--verify-receipt refuses execution-mode flags before probes", () => { + const result = runScript(["--verify-receipt", "--dry-run", "--json"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + reason_code: "invalid_arguments", + state_changed: "none", + }); + }); + + it("derives a stable source id from the canonical git remote", () => { + const first = sourceIdFor(process.cwd()); + const second = sourceIdFor(process.cwd()); + expect(first).toBe(second); + expect(first).toMatch(/^gstack-code-[a-z0-9-]+$/); + }); + it("derived source ids are gbrain-valid (≤32 chars, alnum + interior hyphens, no dots) for any remote", () => { // gbrain enforces source ids to be 1-32 lowercase alnum chars with optional interior // hyphens. Pre-fix, the slug came from canonicalizeRemote() with only `/` and @@ -130,9 +291,9 @@ describe("gstack-gbrain-sync CLI", () => { // which fails validation on both counts. This test exercises the derivation against // controlled remotes by spawning the CLI in a temp git repo. const cases = [ - "https://github.com/radubach/platform.git", // dot in hostname, total > 32 with old slug - "git@github.com:garrytan/gstack.git", // SCP-style remote - "https://gitlab.example.com/team/proj.git", // multi-dot host, non-github + "https://github.com/radubach/platform.git", // dot in hostname, total > 32 with old slug + "git@github.com:garrytan/gstack.git", // SCP-style remote + "https://gitlab.example.com/team/proj.git", // multi-dot host, non-github "https://github.com/some-very-long-org-name/some-very-long-repo-name.git", // forces hash-truncate ]; const VALID_ID = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/; @@ -144,16 +305,7 @@ describe("gstack-gbrain-sync CLI", () => { spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); spawnSync("git", ["remote", "add", "origin", remote], { cwd: repo }); - const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { - encoding: "utf-8", - timeout: 60000, - cwd: repo, - env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome }, - }); - expect(r.status).toBe(0); - const m = (r.stdout || "").match(/gbrain sources add (\S+)/); - expect(m).not.toBeNull(); - const id = m![1]; + const id = sourceIdFor(repo); expect(id.length).toBeLessThanOrEqual(32); expect(id).toMatch(VALID_ID); expect(id.startsWith("gstack-code-")).toBe(true); @@ -174,16 +326,7 @@ describe("gstack-gbrain-sync CLI", () => { spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); // No `git remote add origin` — this is the no-remote case. - const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { - encoding: "utf-8", - timeout: 60000, - cwd: repo, - env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome }, - }); - expect(r.status).toBe(0); - const m = (r.stdout || "").match(/gbrain sources add (\S+)/); - expect(m).not.toBeNull(); - const id = m![1]; + const id = sourceIdFor(repo); expect(id.startsWith("gstack-code-")).toBe(true); expect(id.length).toBeLessThanOrEqual(32); expect(id).toMatch(/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/); @@ -209,16 +352,7 @@ describe("gstack-gbrain-sync CLI", () => { spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); // No `origin` remote — forces the basename-fallback path. - const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { - encoding: "utf-8", - timeout: 60000, - cwd: repo, - env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome }, - }); - expect(r.status).toBe(0); - const m = (r.stdout || "").match(/gbrain sources add (\S+)/); - expect(m).not.toBeNull(); - const id = m![1]; + const id = sourceIdFor(repo); // gbrain validator: 1-32 lowercase alnum + interior hyphens, no leading // or trailing hyphens. expect(id.startsWith("gstack-code-")).toBe(true); @@ -241,48 +375,26 @@ describe("gstack-gbrain-sync CLI", () => { mkdirSync(gstackHome, { recursive: true }); const repo = mkdtempSync(join(tmpdir(), "gstack-host-collide-")); spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); - spawnSync("git", ["remote", "add", "origin", "https://github.com/example/multihost.git"], { cwd: repo }); - - // Dry-run still gates the code stage on `command -v gbrain`. Drop a no-op - // shim on PATH so the stage runs (we only assert the preview line, never - // invoke gbrain itself). - const bindir = mkdtempSync(join(tmpdir(), "gstack-host-collide-bin-")); - const shim = join(bindir, "gbrain"); - writeFileSync(shim, "#!/bin/sh\nexit 0\n"); - chmodSync(shim, 0o755); - const PATH = `${bindir}:${process.env.PATH || ""}`; - - const runAs = (host: string) => - spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { - encoding: "utf-8", - timeout: 60000, - cwd: repo, - env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome, GSTACK_HOSTNAME: host, PATH }, - }); + spawnSync( + "git", + ["remote", "add", "origin", "https://github.com/example/multihost.git"], + { cwd: repo }, + ); - const a = runAs("machine-a"); - const b = runAs("machine-b"); - expect(a.status).toBe(0); - expect(b.status).toBe(0); - const idA = (a.stdout || "").match(/gbrain sources add (\S+)/)?.[1]; - const idB = (b.stdout || "").match(/gbrain sources add (\S+)/)?.[1]; - expect(idA).toBeTruthy(); - expect(idB).toBeTruthy(); + const idA = sourceIdFor(repo, "machine-a"); + const idB = sourceIdFor(repo, "machine-b"); expect(idA).not.toBe(idB); // Both still gbrain-valid. const VALID_ID = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/; - expect(idA!).toMatch(VALID_ID); - expect(idB!).toMatch(VALID_ID); + expect(idA).toMatch(VALID_ID); + expect(idB).toMatch(VALID_ID); // Same host + same path stays stable across invocations. - const a2 = runAs("machine-a"); - expect(a2.status).toBe(0); - const idA2 = (a2.stdout || "").match(/gbrain sources add (\S+)/)?.[1]; + const idA2 = sourceIdFor(repo, "machine-a"); expect(idA2).toBe(idA); rmSync(repo, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); - rmSync(bindir, { recursive: true, force: true }); }); it("dry-run does NOT acquire the lock file (lock is for write paths only)", () => { @@ -298,27 +410,358 @@ describe("gstack-gbrain-sync CLI", () => { rmSync(home, { recursive: true, force: true }); }); - it("a stale lock file (older than 5 min) is taken over, not blocking", () => { + it("holds the wrapper lock across CLI source registration, sync, and attach mutations", () => { + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + const binDir = join(home, "bin"); + const mutationLog = join(home, "gbrain-mutations.log"); + const registryMarker = join(home, "source-registered"); + const lockPath = join(gstackHome, ".sync-gbrain.lock"); + const repo = mkdtempSync(join(tmpdir(), "gstack-lock-contract-")); + mkdirSync(gstackHome, { recursive: true }); + mkdirSync(binDir); + expect( + spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }).status, + ).toBe(0); + expect( + spawnSync("git", ["config", "user.email", "gstack@test.invalid"], { + cwd: repo, + }).status, + ).toBe(0); + expect( + spawnSync("git", ["config", "user.name", "gstack test"], { + cwd: repo, + }).status, + ).toBe(0); + writeFileSync(join(repo, "README.md"), "# lock contract\n"); + expect(spawnSync("git", ["add", "README.md"], { cwd: repo }).status).toBe( + 0, + ); + expect( + spawnSync("git", ["commit", "--quiet", "-m", "fixture"], { cwd: repo }) + .status, + ).toBe(0); + + const canonicalRepo = spawnSync("git", ["rev-parse", "--show-toplevel"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const sourceId = sourceIdFor(canonicalRepo); + const head = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf-8", + }).stdout.trim(); + const sourceSnapshot = JSON.stringify({ + sources: [ + { + id: sourceId, + local_path: ".", + last_commit: head, + last_successful_strategy: "auto", + }, + ], + }); + const planDigest = "a".repeat(64); + const syncResult = JSON.stringify({ + schema_version: 1, + result_kind: "gbrain_sync", + status: "up_to_date", + source: { id: sourceId }, + repository: { + from_commit: head, + target_commit: head, + bookmark_after: head, + last_successful_strategy: "auto", + }, + strategy: "auto", + strategy_changed: false, + operations: { added: 0, modified: 0, deleted: 0, renamed: 0 }, + affected: { + total: 0, + sample_limit: 100, + sample: [], + truncated: false, + }, + affected_digest: + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + plan_digest: planDigest, + corpus: { + markdown_planned_or_applied: 0, + code_pages_before: 0, + code_pages_after: 0, + code_deletions_applied: 0, + image_operations_applied: 0, + image_pages_after: 0, + multimodal_enabled: false, + embedding_status: "deferred", + extraction_status: "deferred", + search_ready: false, + }, + }); + const previewResult = JSON.stringify({ + ...JSON.parse(syncResult), + status: "dry_run", + preview_kind: "validated_index_plan", + repository: { + ...JSON.parse(syncResult).repository, + bookmark_after: head, + }, + }); + const fakeGbrain = join(binDir, "gbrain"); + writeFileSync( + fakeGbrain, + `#!/bin/sh +assert_wrapper_lock() { + if [ ! -f "$GSTACK_HOME/.sync-gbrain.lock" ]; then + printf 'wrapper lock missing for %s\\n' "$*" >&2 + exit 91 + fi + lock_payload=$(read_lock_payload < "$GSTACK_HOME/.sync-gbrain.lock") + case "$lock_payload" in + *'"pid":'"$PPID"','*'"started_at":'*) ;; + *) printf 'wrapper lock malformed for %s\\n' "$*" >&2; exit 92 ;; + esac + printf '%s lock=held\\n' "$*" >> "$GSTACK_TEST_GBRAIN_LOG" +} +read_lock_payload() { + IFS= read -r payload + printf '%s' "$payload" +} +if [ "$1" = "--version" ]; then + printf 'gbrain 0.42.71.0\\n' + exit 0 +fi +if [ "$1" = "sources" ] && [ "$2" = "list" ]; then + if [ -f "$GSTACK_TEST_REGISTRY_MARKER" ]; then + printf '%s\\n' "$GSTACK_TEST_SOURCE_SNAPSHOT" + else + printf '{"sources":[]}\\n' + fi + exit 0 +fi +if [ "$1" = "sources" ] && [ "$2" = "add" ]; then + assert_wrapper_lock "$@" + : > "$GSTACK_TEST_REGISTRY_MARKER" + exit 0 +fi +if [ "$1" = "sync" ]; then + assert_wrapper_lock "$@" + case " $* " in + *" --dry-run "*) + printf '%s\\n' "$GSTACK_TEST_PREVIEW_RESULT" + exit 0 + ;; + *" --expected-plan-digest $GSTACK_TEST_PLAN_DIGEST "*) + printf '%s\\n' "$GSTACK_TEST_SYNC_RESULT" + exit 0 + ;; + *) + printf 'unbound sync argv: %s\\n' "$*" >&2 + exit 94 + ;; + esac +fi +if [ "$1" = "sources" ] && [ "$2" = "attach" ]; then + assert_wrapper_lock "$@" + printf '%s\\n' "$GSTACK_TEST_SOURCE_ID" > "$GSTACK_TEST_REPO/.gbrain-source" + chmod 0644 "$GSTACK_TEST_REPO/.gbrain-source" + exit 0 +fi +printf 'unexpected gbrain argv: %s\\n' "$*" >&2 +exit 93 +`, + ); + chmodSync(fakeGbrain, 0o755); + const env = { + HOME: home, + GSTACK_HOME: gstackHome, + GSTACK_TEST_GBRAIN_LOG: mutationLog, + GSTACK_TEST_REGISTRY_MARKER: registryMarker, + GSTACK_TEST_SOURCE_SNAPSHOT: sourceSnapshot, + GSTACK_TEST_PREVIEW_RESULT: previewResult, + GSTACK_TEST_SYNC_RESULT: syncResult, + GSTACK_TEST_PLAN_DIGEST: planDigest, + GSTACK_TEST_SOURCE_ID: sourceId, + GSTACK_TEST_REPO: repo, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + }; + + try { + const registration = runScript( + ["--code-only", "--json", "--quiet"], + env, + repo, + ); + expect(registration.exitCode).toBe(2); + expect(registration.stderr).toBe(""); + expect(JSON.parse(registration.stdout)).toMatchObject({ + status: "incomplete", + reason_code: "source_registered", + state_changed: "registry_only", + }); + expect(existsSync(lockPath)).toBe(false); + + const sync = runScript(["--code-only", "--json", "--quiet"], env, repo); + expect(sync).toMatchObject({ exitCode: 0, stderr: "" }); + expect(JSON.parse(sync.stdout)).toMatchObject({ + status: "verified", + reason_code: "up_to_date", + state_changed: "applied_verified", + }); + expect(existsSync(lockPath)).toBe(false); + + const mutations = readFileSync(mutationLog, "utf-8").trim().split("\n"); + expect(mutations).toHaveLength(3); + expect(mutations[0]).toContain(`sources add ${sourceId} `); + expect(mutations[1]).toContain( + `sync --strategy auto --source ${sourceId} `, + ); + expect(mutations[1]).toContain("--dry-run"); + expect(mutations[2]).toContain(`--expected-plan-digest ${planDigest}`); + expect(mutations.every((line) => line.endsWith(" lock=held"))).toBe(true); + expect(readFileSync(join(repo, ".gbrain-source"), "utf-8")).toBe( + `${sourceId}\n`, + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); + + it("an old lock owned by a dead process still fails closed", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); mkdirSync(gstackHome, { recursive: true }); // Plant a stale lock file (mtime 6 min ago). const lockPath = join(gstackHome, ".sync-gbrain.lock"); - writeFileSync(lockPath, JSON.stringify({ pid: 99999, started_at: new Date(Date.now() - 6 * 60 * 1000).toISOString() })); + writeFileSync( + lockPath, + JSON.stringify({ + pid: 2147483647, + started_at: new Date(Date.now() - 6 * 60 * 1000).toISOString(), + }), + ); const sixMinAgo = (Date.now() - 6 * 60 * 1000) / 1000; - // Set mtime explicitly via Bun's fs.utimes - const fs = require("fs"); - fs.utimesSync(lockPath, sixMinAgo, sixMinAgo); + utimesSync(lockPath, sixMinAgo, sixMinAgo); + + // The wrapper never auto-breaks an existing lock: PID reuse and + // check/delete ABA races make that unsafe. + const r = runScript( + [ + "--incremental", + "--no-code", + "--no-memory", + "--no-brain-sync", + "--quiet", + ], + { + HOME: home, + GSTACK_HOME: gstackHome, + }, + ); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("ERROR [lock_busy]"); + expect(existsSync(lockPath)).toBe(true); + rmSync(home, { recursive: true, force: true }); + }); - // Run with all stages disabled so we don't actually invoke anything heavy. - const r = runScript(["--incremental", "--no-code", "--no-memory", "--no-brain-sync", "--quiet"], { + it("an old lock owned by a live process remains lock_busy", () => { + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + mkdirSync(gstackHome, { recursive: true }); + const lockPath = join(gstackHome, ".sync-gbrain.lock"); + writeFileSync( + lockPath, + JSON.stringify({ + pid: process.pid, + started_at: new Date(Date.now() - 6 * 60 * 1000).toISOString(), + }), + ); + const sixMinAgo = (Date.now() - 6 * 60 * 1000) / 1000; + utimesSync(lockPath, sixMinAgo, sixMinAgo); + + const r = runScript( + [ + "--incremental", + "--no-code", + "--no-memory", + "--no-brain-sync", + "--quiet", + ], + { HOME: home, GSTACK_HOME: gstackHome }, + ); + + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("ERROR [lock_busy]"); + expect(existsSync(lockPath)).toBe(true); + expect(JSON.parse(readFileSync(lockPath, "utf-8")).pid).toBe(process.pid); + rmSync(home, { recursive: true, force: true }); + }); + + it("stale-lock contenders never enter the mutation path", async () => { + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + const binDir = join(home, "bin"); + const invocationLog = join(home, "gbrain-invocations.log"); + mkdirSync(gstackHome, { recursive: true }); + mkdirSync(binDir); + const lockPath = join(gstackHome, ".sync-gbrain.lock"); + writeFileSync( + lockPath, + JSON.stringify({ + pid: 2147483647, + started_at: new Date(Date.now() - 6 * 60 * 1000).toISOString(), + }), + ); + const sixMinAgo = (Date.now() - 6 * 60 * 1000) / 1000; + utimesSync(lockPath, sixMinAgo, sixMinAgo); + const fakeGbrain = join(binDir, "gbrain"); + writeFileSync( + fakeGbrain, + `#!/bin/sh +if [ "$1" = "--version" ]; then + printf 'version\\n' >> "$GSTACK_TEST_GBRAIN_LOG" + sleep 1 + printf 'gbrain 0.42.71.0\\n' + exit 0 +fi +exit 1 +`, + ); + chmodSync(fakeGbrain, 0o755); + const env = { + ...process.env, HOME: home, GSTACK_HOME: gstackHome, - }); - expect(r.exitCode).toBe(0); - // Lock should be cleared after the run (we took it over and released). - expect(existsSync(lockPath)).toBe(false); + GSTACK_TEST_GBRAIN_LOG: invocationLog, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + }; + const runContender = async () => { + const child = Bun.spawn( + ["bun", SCRIPT, "--code-only", "--json", "--quiet"], + { + cwd: process.cwd(), + env, + stdout: "pipe", + stderr: "pipe", + }, + ); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exitCode, stdout, stderr }; + }; + + const results = await Promise.all([runContender(), runContender()]); + expect( + results.map((result) => JSON.parse(result.stdout).reason_code), + ).toEqual(["lock_busy", "lock_busy"]); + expect(results.map((result) => result.exitCode)).toEqual([2, 2]); + expect(results.every((result) => result.stderr === "")).toBe(true); + expect(existsSync(invocationLog)).toBe(false); rmSync(home, { recursive: true, force: true }); }); @@ -329,29 +772,103 @@ describe("gstack-gbrain-sync CLI", () => { // Plant a fresh lock file (mtime now). const lockPath = join(gstackHome, ".sync-gbrain.lock"); - writeFileSync(lockPath, JSON.stringify({ pid: 99999, started_at: new Date().toISOString() })); + writeFileSync( + lockPath, + JSON.stringify({ pid: 99999, started_at: new Date().toISOString() }), + ); - const r = runScript(["--incremental", "--no-code", "--no-memory", "--no-brain-sync", "--quiet"], { - HOME: home, - GSTACK_HOME: gstackHome, - }); + const r = runScript( + [ + "--incremental", + "--no-code", + "--no-memory", + "--no-brain-sync", + "--quiet", + ], + { + HOME: home, + GSTACK_HOME: gstackHome, + }, + ); expect(r.exitCode).toBe(2); - expect(r.stderr).toContain("another /sync-gbrain is running"); + expect(r.stderr).toContain("ERROR [lock_busy]"); // Lock should still be there — the second invocation didn't take it over. expect(existsSync(lockPath)).toBe(true); rmSync(home, { recursive: true, force: true }); }); + it("--json returns one error document when lock setup itself fails", () => { + const home = makeTestHome(); + const invalidGstackHome = join(home, "not-a-directory"); + writeFileSync(invalidGstackHome, "file blocks mkdir\n"); + + const r = runScript(["--code-only", "--json", "--quiet"], { + HOME: home, + GSTACK_HOME: invalidGstackHome, + }); + + expect(r.exitCode).toBe(1); + expect(r.stderr).toBe(""); + expect(r.stdout.trim().split("\n")).toHaveLength(1); + expect(JSON.parse(r.stdout)).toMatchObject({ + schema_version: 1, + result_kind: "repository_index", + status: "error", + reason_code: "repository_index_failed", + state_changed: "none", + }); + rmSync(home, { recursive: true, force: true }); + }); + + it("reports a lock permission failure as setup/internal error, not contention", () => { + if (process.platform === "win32" || process.getuid?.() === 0) return; + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + mkdirSync(gstackHome, { recursive: true }); + chmodSync(gstackHome, 0o500); + + let r: ReturnType; + try { + r = runScript(["--code-only", "--json", "--quiet"], { + HOME: home, + GSTACK_HOME: gstackHome, + }); + } finally { + chmodSync(gstackHome, 0o700); + } + + expect(r.exitCode).toBe(1); + expect(r.stderr).toBe(""); + expect(r.stdout.trim().split("\n")).toHaveLength(1); + const parsed = JSON.parse(r.stdout); + expect(parsed).toMatchObject({ + status: "error", + reason_code: "repository_index_failed", + state_changed: "none", + }); + expect(parsed.reason_code).not.toBe("lock_busy"); + rmSync(home, { recursive: true, force: true }); + }); + it("writes a state file with schema_version: 1 after a non-dry run", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); mkdirSync(gstackHome, { recursive: true }); // Run with all stages disabled to avoid actually invoking gbrain/memory-ingest - const r = runScript(["--incremental", "--no-code", "--no-memory", "--no-brain-sync", "--quiet"], { - HOME: home, - GSTACK_HOME: gstackHome, - }); + const r = runScript( + [ + "--incremental", + "--no-code", + "--no-memory", + "--no-brain-sync", + "--quiet", + ], + { + HOME: home, + GSTACK_HOME: gstackHome, + }, + ); expect(r.exitCode).toBe(0); const statePath = join(gstackHome, ".gbrain-sync-state.json"); @@ -381,12 +898,23 @@ describe("gstack-gbrain-sync CLI", () => { const gstackHome = join(home, ".gstack"); mkdirSync(gstackHome, { recursive: true }); - runScript(["--incremental", "--no-code", "--no-memory", "--no-brain-sync", "--quiet"], { - HOME: home, - GSTACK_HOME: gstackHome, - }); + runScript( + [ + "--incremental", + "--no-code", + "--no-memory", + "--no-brain-sync", + "--quiet", + ], + { + HOME: home, + GSTACK_HOME: gstackHome, + }, + ); - const state = JSON.parse(readFileSync(join(gstackHome, ".gbrain-sync-state.json"), "utf-8")); + const state = JSON.parse( + readFileSync(join(gstackHome, ".gbrain-sync-state.json"), "utf-8"), + ); expect(Array.isArray(state.last_stages)).toBe(true); // With all stages disabled, last_stages is empty expect(state.last_stages.length).toBe(0); @@ -432,18 +960,7 @@ describe("gstack-gbrain-sync CLI", () => { spawnSync("git", ["remote", "add", "origin", remote], { cwd: repo }); } - const idOf = (cwd: string): string => { - const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { - encoding: "utf-8", - timeout: 60000, - cwd, - env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome }, - }); - expect(r.status).toBe(0); - const m = (r.stdout || "").match(/gbrain sources add (\S+)/); - expect(m).not.toBeNull(); - return m![1]; - }; + const idOf = (cwd: string): string => sourceIdFor(cwd); const idA = idOf(repoA); const idB = idOf(repoB); @@ -468,79 +985,72 @@ describe("gstack-gbrain-sync CLI", () => { spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); spawnSync("git", ["remote", "add", "origin", remote], { cwd: repo }); - const idOf = (): string => { - const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { - encoding: "utf-8", - timeout: 60000, - cwd: repo, - env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome }, - }); - expect(r.status).toBe(0); - const m = (r.stdout || "").match(/gbrain sources add (\S+)/); - expect(m).not.toBeNull(); - return m![1]; - }; + const idOf = (): string => sourceIdFor(repo); expect(idOf()).toBe(idOf()); rmSync(repo, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); }); - it("dry-run preview includes legacy-source removal + attach (post-codex-review hardening)", () => { - // Codex adversarial flagged: pre-pathhash `gstack-code-` sources stay - // orphaned forever after the new pathhash id ships. Dry-run preview must - // surface the legacy cleanup so the user knows it'll happen. + it("dry-run never previews legacy cleanup or executable source mutation", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); mkdirSync(gstackHome, { recursive: true }); const repo = mkdtempSync(join(tmpdir(), "gstack-legacy-cleanup-")); spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); - spawnSync("git", ["remote", "add", "origin", "https://github.com/garrytan/gstack.git"], { cwd: repo }); + spawnSync( + "git", + ["remote", "add", "origin", "https://github.com/garrytan/gstack.git"], + { cwd: repo }, + ); - const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { - encoding: "utf-8", - timeout: 60000, - cwd: repo, - env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome }, - }); + const r = spawnSync( + "bun", + [SCRIPT, "--dry-run", "--code-only", "--quiet"], + { + encoding: "utf-8", + timeout: 60000, + cwd: repo, + env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome }, + }, + ); expect(r.status).toBe(0); - // The dry-run preview shows what WOULD run; the live path will also - // remove the legacy source via `gbrain sources remove gstack-code- - // --confirm-destructive` when that legacy source is registered. We can't - // assert the remove step in dry-run because the orchestrator's preview - // string lists what it would do, but the legacy removal is gated on the - // legacy id being registered (which we can't probe in a sandboxed test - // without a real gbrain CLI). Instead, assert the preview still includes - // the new flow (sources add + sync + attach) at minimum. - expect(r.stdout).toMatch(/gbrain sources add gstack-code-/); - expect(r.stdout).toMatch(/gbrain sync --strategy code --source gstack-code-/); - expect(r.stdout).toMatch(/gbrain sources attach gstack-code-/); + expect(r.stdout).toContain("ORCHESTRATION PREVIEW — unvalidated"); + expect(r.stdout).not.toContain("sources remove"); + expect(r.stdout).not.toContain("sources add"); + expect(r.stdout).not.toContain("sources attach"); rmSync(repo, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); }); - it("dry-run preview includes the `sources attach` step (kubectl-style CWD pin)", () => { - // Post-spike redesign: after sources add + sync, /sync-gbrain calls - // `gbrain sources attach ` so subsequent gbrain code-def / code-refs - // calls from anywhere under the worktree route to this source by default. - // The dry-run preview must surface that step so the user knows what we - // would do. + it("dry-run says attach remains unvalidated", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); mkdirSync(gstackHome, { recursive: true }); const repo = mkdtempSync(join(tmpdir(), "gstack-attach-preview-")); spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); - spawnSync("git", ["remote", "add", "origin", "https://github.com/garrytan/gstack.git"], { cwd: repo }); + spawnSync( + "git", + ["remote", "add", "origin", "https://github.com/garrytan/gstack.git"], + { cwd: repo }, + ); - const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { - encoding: "utf-8", - timeout: 60000, - cwd: repo, - env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome }, - }); + const r = spawnSync( + "bun", + [SCRIPT, "--dry-run", "--code-only", "--quiet"], + { + encoding: "utf-8", + timeout: 60000, + cwd: repo, + env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome }, + }, + ); expect(r.status).toBe(0); - expect(r.stdout).toMatch(/gbrain sources attach gstack-code-/); + expect(r.stdout).toContain( + "No engine/source/path/content compatibility was proven.", + ); + expect(r.stdout).not.toContain("gbrain sources attach"); rmSync(repo, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); @@ -562,17 +1072,25 @@ describe("gstack-gbrain-sync CLI", () => { * output, then return PATH-prepend value. Lets us run helpers in-process * (which spawn `gbrain` from PATH) without a real gbrain CLI. */ -function makeShim(bindir: string, responses: Record): string { +function makeShim( + bindir: string, + responses: Record< + string, + { stdout?: string; stderr?: string; exit?: number } + >, +): string { const shim = join(bindir, "gbrain"); - const cases = Object.entries(responses).map(([key, r]) => { - const exit = r.exit ?? 0; - const stdout = (r.stdout || "").replace(/'/g, "'\\''"); - const stderr = (r.stderr || "").replace(/'/g, "'\\''"); - // Patterns with spaces MUST be double-quoted in sh case statements, - // otherwise the shell parses the second word as the start of the next - // pattern and errors out. - return ` "${key}") printf '%s' '${stdout}'; printf '%s' '${stderr}' >&2; exit ${exit} ;;`; - }).join("\n"); + const cases = Object.entries(responses) + .map(([key, r]) => { + const exit = r.exit ?? 0; + const stdout = (r.stdout || "").replace(/'/g, "'\\''"); + const stderr = (r.stderr || "").replace(/'/g, "'\\''"); + // Patterns with spaces MUST be double-quoted in sh case statements, + // otherwise the shell parses the second word as the start of the next + // pattern and errors out. + return ` "${key}") printf '%s' '${stdout}'; printf '%s' '${stderr}' >&2; exit ${exit} ;;`; + }) + .join("\n"); // Match on the full argument string, joined with literal spaces. const script = `#!/bin/sh\nARGS="$*"\ncase "$ARGS" in\n${cases}\n *) echo "shim: no match for [$ARGS]" >&2; exit 1 ;;\nesac\n`; writeFileSync(shim, script); @@ -587,7 +1105,11 @@ describe("derivePathOnlyHashLegacyId", () => { // didn't include hostname. const repo = mkdtempSync(join(tmpdir(), "gstack-legacy-id-")); spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); - spawnSync("git", ["remote", "add", "origin", "https://github.com/example/legacy-test.git"], { cwd: repo }); + spawnSync( + "git", + ["remote", "add", "origin", "https://github.com/example/legacy-test.git"], + { cwd: repo }, + ); const cwd = process.cwd(); try { @@ -614,30 +1136,19 @@ describe("derivePathOnlyHashLegacyId", () => { // can detect + clean up the orphan. const repo = mkdtempSync(join(tmpdir(), "gstack-legacy-id-distinct-")); spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); - spawnSync("git", ["remote", "add", "origin", "https://github.com/example/distinct.git"], { cwd: repo }); + spawnSync( + "git", + ["remote", "add", "origin", "https://github.com/example/distinct.git"], + { cwd: repo }, + ); const cwd = process.cwd(); try { process.chdir(repo); process.env.GSTACK_HOSTNAME = "machine-x"; const legacy = derivePathOnlyHashLegacyId(repo); - // Drive the new id through the CLI so we use the same code path users hit. - const home = makeTestHome(); - const gstackHome = join(home, ".gstack"); - mkdirSync(gstackHome, { recursive: true }); - const bindir = mkdtempSync(join(tmpdir(), "gstack-legacy-id-distinct-bin-")); - makeShim(bindir, { "--help": { stdout: "gbrain\n" } }); - const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { - encoding: "utf-8", - timeout: 60000, - cwd: repo, - env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome, GSTACK_HOSTNAME: "machine-x", PATH: `${bindir}:${process.env.PATH || ""}` }, - }); - const newId = (r.stdout || "").match(/gbrain sources add (\S+)/)?.[1]; - expect(newId).toBeTruthy(); + const newId = deriveCodeSourceId(repo); expect(newId).not.toBe(legacy); - rmSync(home, { recursive: true, force: true }); - rmSync(bindir, { recursive: true, force: true }); } finally { delete process.env.GSTACK_HOSTNAME; process.chdir(cwd); @@ -668,7 +1179,11 @@ describe("planHostnameFoldMigration", () => { }); it("returns ids-match when legacy == new (degenerate case)", () => { - const result = planHostnameFoldMigration("/repo/path", "gstack-code-same-abc12345", "gstack-code-same-abc12345"); + const result = planHostnameFoldMigration( + "/repo/path", + "gstack-code-same-abc12345", + "gstack-code-same-abc12345", + ); expect(result).toEqual({ kind: "none", reason: "ids-match" }); }); @@ -676,17 +1191,29 @@ describe("planHostnameFoldMigration", () => { makeShim(bindir, { "sources list --json": { stdout: "[]" }, }); - const result = planHostnameFoldMigration("/repo/path", "new-id", "legacy-id", envWithBindir(bindir)); + const result = planHostnameFoldMigration( + "/repo/path", + "new-id", + "legacy-id", + envWithBindir(bindir), + ); expect(result).toEqual({ kind: "none", reason: "no-legacy-source" }); }); it("returns skipped-path-drift when old source local_path differs from current repo root", () => { makeShim(bindir, { "sources list --json": { - stdout: JSON.stringify([{ id: "legacy-id", local_path: "/some/other/repo" }]), + stdout: JSON.stringify([ + { id: "legacy-id", local_path: "/some/other/repo" }, + ]), }, }); - const result = planHostnameFoldMigration("/repo/here", "new-id", "legacy-id", envWithBindir(bindir)); + const result = planHostnameFoldMigration( + "/repo/here", + "new-id", + "legacy-id", + envWithBindir(bindir), + ); expect(result.kind).toBe("skipped-path-drift"); if (result.kind === "skipped-path-drift") { expect(result.oldId).toBe("legacy-id"); @@ -705,8 +1232,17 @@ describe("planHostnameFoldMigration", () => { }, "sources rename legacy-id new-id": { exit: 0 }, }); - const result = planHostnameFoldMigration("/repo/here", "new-id", "legacy-id", envWithBindir(bindir)); - expect(result).toEqual({ kind: "renamed", oldId: "legacy-id", newId: "new-id" }); + const result = planHostnameFoldMigration( + "/repo/here", + "new-id", + "legacy-id", + envWithBindir(bindir), + ); + expect(result).toEqual({ + kind: "renamed", + oldId: "legacy-id", + newId: "new-id", + }); }); it("returns pending-cleanup when rename is unsupported (current gbrain 0.35.0.0)", () => { @@ -716,7 +1252,12 @@ describe("planHostnameFoldMigration", () => { }, // No `sources rename --help` match → shim falls into the catch-all and exits 1. }); - const result = planHostnameFoldMigration("/repo/here", "new-id", "legacy-id", envWithBindir(bindir)); + const result = planHostnameFoldMigration( + "/repo/here", + "new-id", + "legacy-id", + envWithBindir(bindir), + ); expect(result).toEqual({ kind: "pending-cleanup", oldId: "legacy-id" }); }); @@ -728,9 +1269,17 @@ describe("planHostnameFoldMigration", () => { "sources rename --help": { stdout: "Usage: gbrain sources rename \n", }, - "sources rename legacy-id new-id": { exit: 1, stderr: "rename failed: db locked" }, + "sources rename legacy-id new-id": { + exit: 1, + stderr: "rename failed: db locked", + }, }); - const result = planHostnameFoldMigration("/repo/here", "new-id", "legacy-id", envWithBindir(bindir)); + const result = planHostnameFoldMigration( + "/repo/here", + "new-id", + "legacy-id", + envWithBindir(bindir), + ); expect(result).toEqual({ kind: "pending-cleanup", oldId: "legacy-id" }); }); }); @@ -742,8 +1291,8 @@ describe("constrainSourceId truncation (hyphen-boundary cut)", () => { // `kill-270c0001` (from `skill`). The new tokenized cut walks hyphen // boundaries from the right and only keeps whole tokens. // - // Exercised via the dry-run preview (`gbrain sources add gstack-code-…`), - // since constrainSourceId is module-private. + // Exercised through the exported source-id derivation helper; dry-run no + // longer inspects Git or prints an executable registration command. it("never produces mid-word truncation artifacts like `kill` (from `skill`)", () => { const home = makeTestHome(); const gstackHome = join(home, ".gstack"); @@ -752,23 +1301,24 @@ describe("constrainSourceId truncation (hyphen-boundary cut)", () => { spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); // Remote chosen to be long enough that constrainSourceId truncates and // the boundary lands inside the word `skill`. - spawnSync("git", ["remote", "add", "origin", "https://github.com/drummerms-av-sow-wiz/skill-270c0001.git"], { cwd: repo }); + spawnSync( + "git", + [ + "remote", + "add", + "origin", + "https://github.com/drummerms-av-sow-wiz/skill-270c0001.git", + ], + { cwd: repo }, + ); - const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { - encoding: "utf-8", - timeout: 60000, - cwd: repo, - env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome }, - }); - expect(r.status).toBe(0); - const id = (r.stdout || "").match(/gbrain sources add (\S+)/)?.[1]; - expect(id).toBeTruthy(); + const id = sourceIdFor(repo); // The id must not contain the mid-word fragment `kill` (left over from // slicing inside `skill`). Tokens that survive truncation must be whole. expect(id).not.toMatch(/(^|-)kill(-|$)/); // Still gbrain-valid. - expect(id!.length).toBeLessThanOrEqual(32); - expect(id!).toMatch(/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/); + expect(id.length).toBeLessThanOrEqual(32); + expect(id).toMatch(/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/); rmSync(repo, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); @@ -784,19 +1334,15 @@ describe("constrainSourceId truncation (hyphen-boundary cut)", () => { mkdirSync(gstackHome, { recursive: true }); const repo = mkdtempSync(join(tmpdir(), "gstack-https-period-")); spawnSync("git", ["init", "--quiet", "-b", "main"], { cwd: repo }); - spawnSync("git", ["remote", "add", "origin", "https://github.com/foo/bar.git"], { cwd: repo }); + spawnSync( + "git", + ["remote", "add", "origin", "https://github.com/foo/bar.git"], + { cwd: repo }, + ); - const r = spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], { - encoding: "utf-8", - timeout: 60000, - cwd: repo, - env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome }, - }); - expect(r.status).toBe(0); - const id = (r.stdout || "").match(/gbrain sources add (\S+)/)?.[1]; - expect(id).toBeTruthy(); + const id = sourceIdFor(repo); expect(id).not.toContain("."); - expect(id!).toMatch(/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/); + expect(id).toMatch(/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/); rmSync(repo, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); @@ -821,7 +1367,9 @@ describe("sourceLocalPath", () => { ]), }, }); - expect(sourceLocalPath("target-id", envWithBindir(bindir))).toBe("/repo/match"); + expect(sourceLocalPath("target-id", envWithBindir(bindir))).toBe( + "/repo/match", + ); }); it("returns null when the source is missing", () => { @@ -853,7 +1401,9 @@ describe("sourceLocalPath", () => { }), }, }); - expect(sourceLocalPath("target-id", envWithBindir(bindir))).toBe("/repo/match"); + expect(sourceLocalPath("target-id", envWithBindir(bindir))).toBe( + "/repo/match", + ); }); it("returns null when the source is missing in the wrapped shape", () => { diff --git a/test/regression-1611-gbrain-sync-resume.test.ts b/test/regression-1611-gbrain-sync-resume.test.ts index 1f4a06a89c..b5c17f52dc 100644 --- a/test/regression-1611-gbrain-sync-resume.test.ts +++ b/test/regression-1611-gbrain-sync-resume.test.ts @@ -34,7 +34,7 @@ import { resolveStageTimeoutMs, readGbrainCheckpoint, decideResume, -} from "../bin/gstack-gbrain-sync"; +} from "../bin/gstack-gbrain-sync.ts"; import { checkOwnedStagingDir, STAGING_MARKER } from "../lib/staging-guard"; import { stagedRelPath, readNewFailures } from "../bin/gstack-memory-ingest"; @@ -45,45 +45,69 @@ const MAX_MS = 86_400_000; describe("#1611 resolveStageTimeoutMs — env parsing + bounds", () => { test("undefined env → default 2_100_000ms (unchanged from prior behavior)", () => { - expect(resolveStageTimeoutMs(undefined, "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS); + expect( + resolveStageTimeoutMs(undefined, "GSTACK_SYNC_MEMORY_TIMEOUT_MS"), + ).toBe(DEFAULT_MS); }); test("empty string env → default", () => { - expect(resolveStageTimeoutMs("", "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS); + expect(resolveStageTimeoutMs("", "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe( + DEFAULT_MS, + ); }); test("non-numeric env → warn + default", () => { - expect(resolveStageTimeoutMs("not-a-number", "GSTACK_SYNC_CODE_TIMEOUT_MS")).toBe(DEFAULT_MS); + expect( + resolveStageTimeoutMs("not-a-number", "GSTACK_SYNC_CODE_TIMEOUT_MS"), + ).toBe(DEFAULT_MS); }); test("zero env → warn + default (not positive)", () => { - expect(resolveStageTimeoutMs("0", "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS); + expect(resolveStageTimeoutMs("0", "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe( + DEFAULT_MS, + ); }); test("negative env → warn + default", () => { - expect(resolveStageTimeoutMs("-1000", "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS); + expect( + resolveStageTimeoutMs("-1000", "GSTACK_SYNC_MEMORY_TIMEOUT_MS"), + ).toBe(DEFAULT_MS); }); test("below 60_000ms floor (1min) → warn + default", () => { - expect(resolveStageTimeoutMs("30000", "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS); - expect(resolveStageTimeoutMs(`${MIN_MS - 1}`, "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS); + expect( + resolveStageTimeoutMs("30000", "GSTACK_SYNC_MEMORY_TIMEOUT_MS"), + ).toBe(DEFAULT_MS); + expect( + resolveStageTimeoutMs(`${MIN_MS - 1}`, "GSTACK_SYNC_MEMORY_TIMEOUT_MS"), + ).toBe(DEFAULT_MS); }); test("above 86_400_000ms ceiling (24h) → warn + default", () => { - expect(resolveStageTimeoutMs(`${MAX_MS + 1}`, "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(DEFAULT_MS); - expect(resolveStageTimeoutMs("999999999999", "GSTACK_SYNC_CODE_TIMEOUT_MS")).toBe(DEFAULT_MS); + expect( + resolveStageTimeoutMs(`${MAX_MS + 1}`, "GSTACK_SYNC_MEMORY_TIMEOUT_MS"), + ).toBe(DEFAULT_MS); + expect( + resolveStageTimeoutMs("999999999999", "GSTACK_SYNC_CODE_TIMEOUT_MS"), + ).toBe(DEFAULT_MS); }); test("at floor (60_000ms exactly) → accepted", () => { - expect(resolveStageTimeoutMs(`${MIN_MS}`, "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(MIN_MS); + expect( + resolveStageTimeoutMs(`${MIN_MS}`, "GSTACK_SYNC_MEMORY_TIMEOUT_MS"), + ).toBe(MIN_MS); }); test("at ceiling (86_400_000ms exactly) → accepted", () => { - expect(resolveStageTimeoutMs(`${MAX_MS}`, "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(MAX_MS); + expect( + resolveStageTimeoutMs(`${MAX_MS}`, "GSTACK_SYNC_MEMORY_TIMEOUT_MS"), + ).toBe(MAX_MS); }); test("valid mid-range (2h = 7_200_000ms) → returns value", () => { - expect(resolveStageTimeoutMs("7200000", "GSTACK_SYNC_MEMORY_TIMEOUT_MS")).toBe(7_200_000); + expect( + resolveStageTimeoutMs("7200000", "GSTACK_SYNC_MEMORY_TIMEOUT_MS"), + ).toBe(7_200_000); }); }); @@ -138,14 +162,22 @@ describe("#1611 decideResume — checkpoint + staging detection", () => { fs.mkdirSync(stagingDir, { recursive: true }); fs.writeFileSync(stagingDir + "/page1.md", "content", "utf-8"); // #1802: a real staging dir carries the ownership marker minted by makeStagingDir. - fs.writeFileSync(path.join(stagingDir, STAGING_MARKER), "99\n99\n", "utf-8"); - fs.writeFileSync(cpPath, JSON.stringify({ - dir: stagingDir, - totalFiles: 1989, - processedIndex: 1000, - completedFiles: 1000, - timestamp: "2026-05-19T19:30:05.008Z", - }), "utf-8"); + fs.writeFileSync( + path.join(stagingDir, STAGING_MARKER), + "99\n99\n", + "utf-8", + ); + fs.writeFileSync( + cpPath, + JSON.stringify({ + dir: stagingDir, + totalFiles: 1989, + processedIndex: 1000, + completedFiles: 1000, + timestamp: "2026-05-19T19:30:05.008Z", + }), + "utf-8", + ); // gstackHome is injected so the ownership check anchors on the test home. const v = decideResume(tmpHome); @@ -159,11 +191,15 @@ describe("#1611 decideResume — checkpoint + staging detection", () => { test("checkpoint references missing staging dir → stale-staging-missing", () => { // Note: stagingDir is NOT created on disk for this test - fs.writeFileSync(cpPath, JSON.stringify({ - dir: stagingDir, - totalFiles: 1989, - processedIndex: 1000, - }), "utf-8"); + fs.writeFileSync( + cpPath, + JSON.stringify({ + dir: stagingDir, + totalFiles: 1989, + processedIndex: 1000, + }), + "utf-8", + ); const v = decideResume(tmpHome); expect(v.kind).toBe("stale-staging-missing"); @@ -180,8 +216,16 @@ describe("#1611 decideResume — checkpoint + staging detection", () => { // code resumed (and cleanup later rm -rf'd it). It must now be refused. const repoRoot = path.join(tmpHome, "my-repo"); fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); - fs.writeFileSync(path.join(repoRoot, "important.py"), "# real work\n", "utf-8"); - fs.writeFileSync(cpPath, JSON.stringify({ dir: repoRoot, totalFiles: 10, processedIndex: 3 }), "utf-8"); + fs.writeFileSync( + path.join(repoRoot, "important.py"), + "# real work\n", + "utf-8", + ); + fs.writeFileSync( + cpPath, + JSON.stringify({ dir: repoRoot, totalFiles: 10, processedIndex: 3 }), + "utf-8", + ); const v = decideResume(tmpHome); expect(v.kind).toBe("stale-staging-missing"); @@ -191,28 +235,44 @@ describe("#1611 decideResume — checkpoint + staging detection", () => { test("#1802 staging-named dir WITHOUT marker → stale-staging-missing (not minted by us)", () => { fs.mkdirSync(stagingDir, { recursive: true }); // .staging-ingest-99-99, but no marker - fs.writeFileSync(cpPath, JSON.stringify({ dir: stagingDir, totalFiles: 1, processedIndex: 0 }), "utf-8"); + fs.writeFileSync( + cpPath, + JSON.stringify({ dir: stagingDir, totalFiles: 1, processedIndex: 0 }), + "utf-8", + ); expect(decideResume(tmpHome).kind).toBe("stale-staging-missing"); }); test("#1802 checkpoint.dir = '/' → stale-staging-missing", () => { - fs.writeFileSync(cpPath, JSON.stringify({ dir: "/", totalFiles: 1, processedIndex: 0 }), "utf-8"); + fs.writeFileSync( + cpPath, + JSON.stringify({ dir: "/", totalFiles: 1, processedIndex: 0 }), + "utf-8", + ); expect(decideResume(tmpHome).kind).toBe("stale-staging-missing"); }); test("checkpoint with no dir field → no-checkpoint verdict", () => { - fs.writeFileSync(cpPath, JSON.stringify({ - totalFiles: 1989, - processedIndex: 1000, - }), "utf-8"); + fs.writeFileSync( + cpPath, + JSON.stringify({ + totalFiles: 1989, + processedIndex: 1000, + }), + "utf-8", + ); expect(decideResume().kind).toBe("no-checkpoint"); }); test("checkpoint with empty dir string → no-checkpoint verdict", () => { - fs.writeFileSync(cpPath, JSON.stringify({ - dir: "", - }), "utf-8"); + fs.writeFileSync( + cpPath, + JSON.stringify({ + dir: "", + }), + "utf-8", + ); expect(decideResume().kind).toBe("no-checkpoint"); }); @@ -232,7 +292,9 @@ describe("#1611 SIGTERM staging preservation — static invariants", () => { const handlerStart = body.indexOf("if (_activeStagingDir)"); expect(handlerStart).toBeGreaterThan(-1); const handlerSlice = body.slice(handlerStart, handlerStart + 1000); - const preserveAt = handlerSlice.indexOf("preserving staging dir for resume"); + const preserveAt = handlerSlice.indexOf( + "preserving staging dir for resume", + ); const cleanupAt = handlerSlice.indexOf("cleanupStagingDir"); expect(preserveAt).toBeGreaterThan(-1); expect(cleanupAt).toBeGreaterThan(-1); @@ -273,7 +335,11 @@ describe("#1802 checkOwnedStagingDir — ownership matrix", () => { home = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-1802-")); }); afterEach(() => { - try { fs.rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ } + try { + fs.rmSync(home, { recursive: true, force: true }); + } catch { + /* best-effort */ + } }); function mintStaging(name = ".staging-ingest-1-1"): string { @@ -330,7 +396,9 @@ describe("#1802 checkOwnedStagingDir — ownership matrix", () => { }); test("missing path → refused (unresolvable)", () => { - expect(checkOwnedStagingDir(path.join(home, ".staging-ingest-gone"), home).ok).toBe(false); + expect( + checkOwnedStagingDir(path.join(home, ".staging-ingest-gone"), home).ok, + ).toBe(false); }); test("'/' and '' → refused", () => { @@ -347,13 +415,23 @@ describe("#1802 checkOwnedStagingDir — ownership matrix", () => { // realpathSync resolves the link to `outside`, whose parent is not `home`. expect(checkOwnedStagingDir(link, home).ok).toBe(false); } finally { - try { fs.rmSync(outside, { recursive: true, force: true }); } catch { /* best-effort */ } + try { + fs.rmSync(outside, { recursive: true, force: true }); + } catch { + /* best-effort */ + } } }); test("cleanupStagingDir + decideResume both call the guard (static invariant)", () => { - const ingest = fs.readFileSync(path.join(ROOT, "bin", "gstack-memory-ingest.ts"), "utf-8"); - const sync = fs.readFileSync(path.join(ROOT, "bin", "gstack-gbrain-sync.ts"), "utf-8"); + const ingest = fs.readFileSync( + path.join(ROOT, "bin", "gstack-memory-ingest.ts"), + "utf-8", + ); + const sync = fs.readFileSync( + path.join(ROOT, "bin", "gstack-gbrain-sync.ts"), + "utf-8", + ); expect(ingest).toMatch(/checkOwnedStagingDir\(dir, GSTACK_HOME\)/); expect(ingest).toMatch(/staging cleanup REFUSED/); expect(sync).toMatch(/checkOwnedStagingDir\(stagingDir, gstackHome\)/); @@ -376,7 +454,9 @@ describe("#1802 D1 — remote-http finally gate (static invariant)", () => { test("finally gates cleanupStagingDir on !remoteHttpMode", () => { // Tolerates additional guards (e.g. C3's !preserveStaging) in the same // condition — the load-bearing invariant is that remote-http never deletes. - expect(ingest).toMatch(/if \(!remoteHttpMode[^)]*\) cleanupStagingDir\(stagingDir\)/); + expect(ingest).toMatch( + /if \(!remoteHttpMode[^)]*\) cleanupStagingDir\(stagingDir\)/, + ); }); test("the only finally-scoped cleanup call is the gated one", () => { @@ -454,7 +534,11 @@ describe("#1802 C4 — resume failure mapping (behavioral)", () => { }); afterEach(() => { for (const d of [dir, cpHome]) { - try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ } + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + /* best-effort */ + } } }); diff --git a/test/skill-e2e-memory-pipeline.test.ts b/test/skill-e2e-memory-pipeline.test.ts index c0f40f6196..fe10263f03 100644 --- a/test/skill-e2e-memory-pipeline.test.ts +++ b/test/skill-e2e-memory-pipeline.test.ts @@ -24,7 +24,15 @@ */ import { describe, it, expect } from "bun:test"; -import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, statSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, + mkdirSync, + statSync, +} from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { spawnSync } from "child_process"; @@ -38,12 +46,19 @@ function makeFixtureHome(): string { return mkdtempSync(join(tmpdir(), "gstack-e2e-pipeline-")); } -function setupFixture(home: string): { gstackHome: string; counts: Record } { +function setupFixture(home: string): { + gstackHome: string; + counts: Record; +} { const gstackHome = join(home, ".gstack"); mkdirSync(gstackHome, { recursive: true }); mkdirSync(join(gstackHome, "analytics"), { recursive: true }); - mkdirSync(join(gstackHome, "projects", "test-repo", "ceo-plans"), { recursive: true }); - mkdirSync(join(gstackHome, "projects", "test-repo", "retros"), { recursive: true }); + mkdirSync(join(gstackHome, "projects", "test-repo", "ceo-plans"), { + recursive: true, + }); + mkdirSync(join(gstackHome, "projects", "test-repo", "retros"), { + recursive: true, + }); // Claude Code session const claudeProjectsDir = join(home, ".claude", "projects", "tmp-test-repo"); @@ -52,7 +67,11 @@ function setupFixture(home: string): { gstackHome: string; counts: Record): { stdout: string; stderr: string; exitCode: number } { +function runBun( + script: string, + args: string[], + env: Record, +): { stdout: string; stderr: string; exitCode: number } { const r = spawnSync("bun", [script, ...args], { encoding: "utf-8", timeout: 60000, env: { ...process.env, ...env }, }); - return { stdout: r.stdout || "", stderr: r.stderr || "", exitCode: r.status ?? 1 }; + return { + stdout: r.stdout || "", + stderr: r.stderr || "", + exitCode: r.status ?? 1, + }; } // ── E2E pipeline ─────────────────────────────────────────────────────────── @@ -101,7 +167,11 @@ describe("V1 memory ingest pipeline E2E", () => { it("--probe finds all 9 fixture files across all source types", () => { const home = makeFixtureHome(); const { gstackHome, counts } = setupFixture(home); - const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" }; + const env = { + HOME: home, + GSTACK_HOME: gstackHome, + GSTACK_MEMORY_INGEST_NO_WRITE: "1", + }; const r = runBun(INGEST, ["--probe"], env); expect(r.exitCode).toBe(0); @@ -121,7 +191,11 @@ describe("V1 memory ingest pipeline E2E", () => { it("--incremental writes a state file with schema_version: 1 + last_writer", () => { const home = makeFixtureHome(); const { gstackHome } = setupFixture(home); - const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" }; + const env = { + HOME: home, + GSTACK_HOME: gstackHome, + GSTACK_MEMORY_INGEST_NO_WRITE: "1", + }; runBun(INGEST, ["--incremental", "--quiet"], env); @@ -138,11 +212,18 @@ describe("V1 memory ingest pipeline E2E", () => { it("--incremental is idempotent — re-run reports 0 changes", () => { const home = makeFixtureHome(); const { gstackHome } = setupFixture(home); - const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" }; + const env = { + HOME: home, + GSTACK_HOME: gstackHome, + GSTACK_MEMORY_INGEST_NO_WRITE: "1", + }; // First run runBun(INGEST, ["--incremental", "--quiet"], env); - const stateAfterFirst = readFileSync(join(gstackHome, ".transcript-ingest-state.json"), "utf-8"); + const stateAfterFirst = readFileSync( + join(gstackHome, ".transcript-ingest-state.json"), + "utf-8", + ); // Second run — without gbrain available, dedup happens at file-change-detection // layer; no put_page calls fire because state shows files unchanged. @@ -155,7 +236,11 @@ describe("V1 memory ingest pipeline E2E", () => { it("--probe shows new vs unchanged distinction after first --incremental", () => { const home = makeFixtureHome(); const { gstackHome } = setupFixture(home); - const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" }; + const env = { + HOME: home, + GSTACK_HOME: gstackHome, + GSTACK_MEMORY_INGEST_NO_WRITE: "1", + }; // First, write some state by running --incremental quietly runBun(INGEST, ["--incremental", "--quiet"], env); @@ -176,19 +261,23 @@ describe("V1 memory ingest pipeline E2E", () => { // ── /gbrain-sync orchestrator E2E ────────────────────────────────────────── describe("V1 /gbrain-sync orchestrator E2E", () => { - it("--dry-run with all stages enabled previews 3 stages", () => { + it("--dry-run short-circuits before every stage probe or preview", () => { const home = makeFixtureHome(); const { gstackHome } = setupFixture(home); - const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" }; + const env = { + HOME: home, + GSTACK_HOME: gstackHome, + GSTACK_MEMORY_INGEST_NO_WRITE: "1", + }; const r = runBun(SYNC, ["--dry-run"], env); expect(r.exitCode).toBe(0); - // Code stage uses native gbrain code surfaces (sources add + sync --strategy code) - // post-codex review; NOT `gbrain import` (markdown-only path). - expect(r.stdout).toContain("would: gbrain sources add"); - expect(r.stdout).toContain("gbrain sync --strategy code"); - expect(r.stdout).toContain("would: gstack-memory-ingest"); - expect(r.stdout).toContain("would: gstack-brain-sync"); + expect(r.stdout.split("\n")[0]).toBe("ORCHESTRATION PREVIEW — unvalidated"); + expect(r.stdout).toContain("GBrain was not contacted."); + expect(r.stdout).toContain("Command state: blocked_until_version_proven"); + expect(r.stdout).not.toContain("gbrain sync"); + expect(r.stdout).not.toContain("gstack-memory-ingest"); + expect(r.stdout).not.toContain("gstack-brain-sync"); rmSync(home, { recursive: true, force: true }); }); @@ -196,9 +285,17 @@ describe("V1 /gbrain-sync orchestrator E2E", () => { it("--no-code --no-brain-sync --incremental runs only memory ingest, writes sync state", () => { const home = makeFixtureHome(); const { gstackHome } = setupFixture(home); - const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" }; - - const r = runBun(SYNC, ["--incremental", "--no-code", "--no-brain-sync", "--quiet"], env); + const env = { + HOME: home, + GSTACK_HOME: gstackHome, + GSTACK_MEMORY_INGEST_NO_WRITE: "1", + }; + + const r = runBun( + SYNC, + ["--incremental", "--no-code", "--no-brain-sync", "--quiet"], + env, + ); expect([0, 1]).toContain(r.exitCode); // memory stage may fail if gbrain CLI is missing; both ok const statePath = join(gstackHome, ".gbrain-sync-state.json"); @@ -221,12 +318,27 @@ describe("V1 retrieval surface — real V1 manifest dispatch", () => { it("loads office-hours/SKILL.md manifest and dispatches 4 queries", () => { const home = makeFixtureHome(); const { gstackHome } = setupFixture(home); - const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" }; + const env = { + HOME: home, + GSTACK_HOME: gstackHome, + GSTACK_MEMORY_INGEST_NO_WRITE: "1", + }; const skillFile = join(REPO_ROOT, "office-hours", "SKILL.md"); expect(existsSync(skillFile)).toBe(true); - const r = runBun(CONTEXT, ["--skill-file", skillFile, "--repo", "test-repo", "--explain", "--quiet"], env); + const r = runBun( + CONTEXT, + [ + "--skill-file", + skillFile, + "--repo", + "test-repo", + "--explain", + "--quiet", + ], + env, + ); expect(r.exitCode).toBe(0); expect(r.stderr).toContain("mode=manifest"); // office-hours has 4 queries (D5/D6 cherry-pick #1 + builder-profile + design-doc + eureka) @@ -242,16 +354,28 @@ describe("V1 retrieval surface — real V1 manifest dispatch", () => { it("renders datamark envelope around every loaded section (Section 1D + D12)", () => { const home = makeFixtureHome(); const { gstackHome } = setupFixture(home); - const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" }; + const env = { + HOME: home, + GSTACK_HOME: gstackHome, + GSTACK_MEMORY_INGEST_NO_WRITE: "1", + }; const skillFile = join(REPO_ROOT, "office-hours", "SKILL.md"); - const r = runBun(CONTEXT, ["--skill-file", skillFile, "--repo", "test-repo"], env); + const r = runBun( + CONTEXT, + ["--skill-file", skillFile, "--repo", "test-repo"], + env, + ); expect(r.exitCode).toBe(0); if (r.stdout.length > 0) { // Every rendered ## section is wrapped in . // Count occurrences: every open tag has a matching close tag. - const opens = (r.stdout.match(//g) || []).length; + const opens = ( + r.stdout.match( + //g, + ) || [] + ).length; const closes = (r.stdout.match(/<\/USER_TRANSCRIPT_DATA>/g) || []).length; expect(opens).toBe(closes); expect(opens).toBeGreaterThan(0); @@ -263,9 +387,17 @@ describe("V1 retrieval surface — real V1 manifest dispatch", () => { it("Layer 1 fallback when no skill specified — default 3-section manifest", () => { const home = makeFixtureHome(); const { gstackHome } = setupFixture(home); - const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" }; - - const r = runBun(CONTEXT, ["--repo", "test-repo", "--explain", "--quiet"], env); + const env = { + HOME: home, + GSTACK_HOME: gstackHome, + GSTACK_MEMORY_INGEST_NO_WRITE: "1", + }; + + const r = runBun( + CONTEXT, + ["--repo", "test-repo", "--explain", "--quiet"], + env, + ); expect(r.exitCode).toBe(0); expect(r.stderr).toContain("mode=default"); expect(r.stderr).toContain("queries=3"); @@ -276,12 +408,27 @@ describe("V1 retrieval surface — real V1 manifest dispatch", () => { it("plan-ceo-review/SKILL.md manifest also dispatches correctly (regression for V1 manifest authoring)", () => { const home = makeFixtureHome(); const { gstackHome } = setupFixture(home); - const env = { HOME: home, GSTACK_HOME: gstackHome, GSTACK_MEMORY_INGEST_NO_WRITE: "1" }; + const env = { + HOME: home, + GSTACK_HOME: gstackHome, + GSTACK_MEMORY_INGEST_NO_WRITE: "1", + }; const skillFile = join(REPO_ROOT, "plan-ceo-review", "SKILL.md"); expect(existsSync(skillFile)).toBe(true); - const r = runBun(CONTEXT, ["--skill-file", skillFile, "--repo", "test-repo", "--explain", "--quiet"], env); + const r = runBun( + CONTEXT, + [ + "--skill-file", + skillFile, + "--repo", + "test-repo", + "--explain", + "--quiet", + ], + env, + ); expect(r.exitCode).toBe(0); expect(r.stderr).toContain("mode=manifest"); expect(r.stderr).toContain("queries=3");