diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2eb2d6af..aaa7f639 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,33 @@ on: tags: - 'v*' workflow_dispatch: + inputs: + release_tag: + description: Release tag to build into a draft, for example v2.0.0 + required: true + type: string + publish_release: + description: Publish the release after all assets are uploaded + required: true + default: false + type: boolean permissions: contents: write +concurrency: + group: release-${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag || github.ref }} + cancel-in-progress: false + +env: + IS_RELEASE_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }} + RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag }} + PUBLISH_RELEASE: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }} + jobs: build: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout @@ -31,32 +51,74 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Set version from tag - if: startsWith(github.ref, 'refs/tags/v') + - name: Validate release metadata + if: ${{ env.IS_RELEASE_BUILD == 'true' }} run: | - VERSION="${GITHUB_REF#refs/tags/v}" + set -euo pipefail + case "${RELEASE_TAG:-}" in + v[0-9]*) ;; + *) + echo "release_tag must start with v and include a numeric version, got '${RELEASE_TAG:-}'" >&2 + exit 1 + ;; + esac + + - name: Set version from release tag + if: ${{ env.IS_RELEASE_BUILD == 'true' }} + run: | + VERSION="${RELEASE_TAG#v}" pnpm pkg set version="$VERSION" + - name: Verify Sentry release env + if: ${{ env.IS_RELEASE_BUILD == 'true' }} + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: node ./scripts/ci/verify-sentry-release.cjs prebuild + - name: Build app env: NODE_OPTIONS: '--max-old-space-size=8192' - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: quant-jump-pro - SENTRY_PROJECT: electron + SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }} + SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: pnpm build + - name: Verify Sentry source map upload + if: ${{ env.IS_RELEASE_BUILD == 'true' }} + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: node ./scripts/ci/verify-sentry-release.cjs postbuild + - name: Create GitHub Release - if: startsWith(github.ref, 'refs/tags/v') + if: ${{ env.IS_RELEASE_BUILD == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -euo pipefail TAG="${GITHUB_REF#refs/tags/}" + TAG="${RELEASE_TAG:-$TAG}" + existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)" + if [ -n "$existing_is_draft" ]; then + if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then + echo "Release $TAG already exists and is not a draft; refusing to overwrite it from draft mode." >&2 + exit 1 + fi + echo "Release $TAG already exists, skipping creation" + exit 0 + fi gh release create "$TAG" \ --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ --title "$TAG" \ --generate-notes \ - --draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation" + --draft=true - name: Upload dist artifact uses: actions/upload-artifact@v7 @@ -69,35 +131,61 @@ jobs: prepare-runtime: runs-on: ubuntu-latest + timeout-minutes: 90 steps: - name: Checkout uses: actions/checkout@v6 + - name: Validate release metadata + if: ${{ env.IS_RELEASE_BUILD == 'true' }} + run: | + set -euo pipefail + case "${RELEASE_TAG:-}" in + v[0-9]*) ;; + *) + echo "release_tag must start with v and include a numeric version, got '${RELEASE_TAG:-}'" >&2 + exit 1 + ;; + esac + - name: Create GitHub Release - if: startsWith(github.ref, 'refs/tags/v') + if: ${{ env.IS_RELEASE_BUILD == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -euo pipefail TAG="${GITHUB_REF#refs/tags/}" + TAG="${RELEASE_TAG:-$TAG}" + existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)" + if [ -n "$existing_is_draft" ]; then + if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then + echo "Release $TAG already exists and is not a draft; refusing to overwrite it from draft mode." >&2 + exit 1 + fi + echo "Release $TAG already exists, skipping creation" + exit 0 + fi gh release create "$TAG" \ --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ --title "$TAG" \ --generate-notes \ - --draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation" + --draft=true - - name: Skip runtime asset preparation for manual builds - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - run: echo "Runtime asset preparation is only needed for tagged releases." + - name: Skip runtime asset preparation for non-release builds + if: ${{ env.IS_RELEASE_BUILD != 'true' }} + run: echo "Runtime asset preparation is only needed for release builds." - name: Check runtime assets - if: startsWith(github.ref, 'refs/tags/v') + if: ${{ env.IS_RELEASE_BUILD == 'true' }} id: runtime-assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail TAG="${GITHUB_REF#refs/tags/}" + TAG="${RELEASE_TAG:-$TAG}" existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)" missing=0 while IFS= read -r asset; do @@ -120,7 +208,7 @@ jobs: exit 1 fi - TARGET_TAG="${GITHUB_REF#refs/tags/}" + TARGET_TAG="${RELEASE_TAG}" SOURCE_REPO="$(node ./scripts/runtime-lock.mjs source-repository)" SOURCE_REF="$(node ./scripts/runtime-lock.mjs source-ref)" RUNTIME_VERSION="$(node ./scripts/runtime-lock.mjs version)" @@ -191,6 +279,7 @@ jobs: run: | set -euo pipefail TAG="${GITHUB_REF#refs/tags/}" + TAG="${RELEASE_TAG:-$TAG}" for attempt in $(seq 1 12); do existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)" all_found=1 @@ -216,6 +305,7 @@ jobs: release-mac: needs: [build, prepare-runtime] + timeout-minutes: 90 strategy: fail-fast: false matrix: @@ -256,10 +346,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Set version from tag - if: startsWith(github.ref, 'refs/tags/v') + - name: Set version from release tag + if: ${{ env.IS_RELEASE_BUILD == 'true' }} run: | - VERSION="${GITHUB_REF#refs/tags/v}" + VERSION="${RELEASE_TAG#v}" pnpm pkg set version="$VERSION" - name: Stage bundled runtime (macOS ${{ matrix.arch }}) @@ -268,21 +358,39 @@ jobs: shell: bash run: | set -euo pipefail - if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then - TAG="${GITHUB_REF#refs/tags/}" + if [[ "${IS_RELEASE_BUILD}" == "true" ]]; then + TAG="${RELEASE_TAG}" node ./scripts/stage-runtime.mjs --platform "darwin-${{ matrix.arch }}" --release-tag "$TAG" else node ./scripts/stage-runtime.mjs --platform "darwin-${{ matrix.arch }}" fi + - name: Verify Sentry release env (macOS ${{ matrix.arch }}) + if: ${{ env.IS_RELEASE_BUILD == 'true' }} + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: node ./scripts/ci/verify-sentry-release.cjs prebuild + - name: Build app (macOS ${{ matrix.arch }}) env: NODE_OPTIONS: '--max-old-space-size=8192' + SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }} + SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: pnpm build + + - name: Verify Sentry source map upload (macOS ${{ matrix.arch }}) + if: ${{ env.IS_RELEASE_BUILD == 'true' }} + env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: quant-jump-pro - SENTRY_PROJECT: electron - run: pnpm build + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: node ./scripts/ci/verify-sentry-release.cjs postbuild - name: Verify packaged inputs (macOS ${{ matrix.arch }}) run: | @@ -309,22 +417,22 @@ jobs: run: node ./scripts/electron-builder/smokePackagedApp.cjs "release/mac-${{ matrix.arch }}/Agent Teams UI.app" darwin - name: Upload assets to release - if: startsWith(github.ref, 'refs/tags/v') + if: ${{ env.IS_RELEASE_BUILD == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - TAG="${GITHUB_REF#refs/tags/}" + TAG="${RELEASE_TAG}" for f in release/*.dmg release/*.zip release/*.blockmap; do [ -f "$f" ] || continue echo "Uploading: $f" gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1 || \ - (sleep 5 && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1) || \ - echo "WARNING: failed to upload $f, continuing..." + (sleep 5 && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1) done release-win: needs: [build, prepare-runtime] runs-on: windows-latest + timeout-minutes: 60 steps: - name: Checkout @@ -354,11 +462,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Set version from tag - if: startsWith(github.ref, 'refs/tags/v') + - name: Set version from release tag + if: ${{ env.IS_RELEASE_BUILD == 'true' }} shell: bash run: | - VERSION="${GITHUB_REF#refs/tags/v}" + VERSION="${RELEASE_TAG#v}" pnpm pkg set version="$VERSION" - name: Stage bundled runtime (Windows) @@ -367,21 +475,39 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | $ErrorActionPreference = "Stop" - if ($env:GITHUB_REF -like 'refs/tags/v*') { - $tag = $env:GITHUB_REF.Replace('refs/tags/', '') + if ($env:IS_RELEASE_BUILD -eq 'true') { + $tag = $env:RELEASE_TAG node ./scripts/stage-runtime.mjs --platform win32-x64 --release-tag $tag } else { node ./scripts/stage-runtime.mjs --platform win32-x64 } + - name: Verify Sentry release env (Windows) + if: ${{ env.IS_RELEASE_BUILD == 'true' }} + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: node ./scripts/ci/verify-sentry-release.cjs prebuild + - name: Build app (Windows) env: NODE_OPTIONS: '--max-old-space-size=8192' + SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }} + SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: pnpm build + + - name: Verify Sentry source map upload (Windows) + if: ${{ env.IS_RELEASE_BUILD == 'true' }} + env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: quant-jump-pro - SENTRY_PROJECT: electron - run: pnpm build + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: node ./scripts/ci/verify-sentry-release.cjs postbuild - name: Verify packaged inputs (Windows) shell: bash @@ -406,23 +532,23 @@ jobs: run: node ./scripts/electron-builder/smokePackagedApp.cjs "release/win-unpacked" win32 - name: Upload assets to release - if: startsWith(github.ref, 'refs/tags/v') + if: ${{ env.IS_RELEASE_BUILD == 'true' }} shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - TAG="${GITHUB_REF#refs/tags/}" + TAG="${RELEASE_TAG}" for f in release/*.exe release/*.blockmap; do [ -f "$f" ] || continue echo "Uploading: $f" gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1 || \ - (sleep 5 && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1) || \ - echo "WARNING: failed to upload $f, continuing..." + (sleep 5 && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1) done release-linux: needs: [build, prepare-runtime] runs-on: ubuntu-latest + timeout-minutes: 90 steps: - name: Checkout @@ -457,10 +583,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Set version from tag - if: startsWith(github.ref, 'refs/tags/v') + - name: Set version from release tag + if: ${{ env.IS_RELEASE_BUILD == 'true' }} run: | - VERSION="${GITHUB_REF#refs/tags/v}" + VERSION="${RELEASE_TAG#v}" pnpm pkg set version="$VERSION" - name: Stage bundled runtime (Linux) @@ -469,21 +595,39 @@ jobs: shell: bash run: | set -euo pipefail - if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then - TAG="${GITHUB_REF#refs/tags/}" + if [[ "${IS_RELEASE_BUILD}" == "true" ]]; then + TAG="${RELEASE_TAG}" node ./scripts/stage-runtime.mjs --platform linux-x64 --release-tag "$TAG" else node ./scripts/stage-runtime.mjs --platform linux-x64 fi + - name: Verify Sentry release env (Linux) + if: ${{ env.IS_RELEASE_BUILD == 'true' }} + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: node ./scripts/ci/verify-sentry-release.cjs prebuild + - name: Build app (Linux) env: NODE_OPTIONS: '--max-old-space-size=8192' + SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }} + SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: pnpm build + + - name: Verify Sentry source map upload (Linux) + if: ${{ env.IS_RELEASE_BUILD == 'true' }} + env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: quant-jump-pro - SENTRY_PROJECT: electron - run: pnpm build + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: node ./scripts/ci/verify-sentry-release.cjs postbuild - name: Verify packaged inputs (Linux) run: | @@ -505,39 +649,39 @@ jobs: run: xvfb-run -a node ./scripts/electron-builder/smokePackagedApp.cjs "release/linux-unpacked" linux - name: Upload assets to release - if: startsWith(github.ref, 'refs/tags/v') + if: ${{ env.IS_RELEASE_BUILD == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -x - TAG="${GITHUB_REF#refs/tags/}" + TAG="${RELEASE_TAG}" ls -la release/ || true for f in release/*.AppImage release/*.deb release/*.rpm release/*.pacman release/*.blockmap; do [ -f "$f" ] || continue echo "Uploading: $f" gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1 || \ - (sleep 5 && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1) || \ - echo "WARNING: failed to upload $f, continuing..." + (sleep 5 && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1) done upload-stable-links: needs: [release-mac, release-win, release-linux] runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') + timeout-minutes: 30 + if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }} steps: - - name: Upload stable-named assets for /latest/download links + - name: Upload compatibility aliases for older updater clients env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - VERSION="${GITHUB_REF#refs/tags/v}" + VERSION="${RELEASE_TAG#v}" + TAG="${RELEASE_TAG}" REPO="${GITHUB_REPOSITORY}" - DOWNLOAD_BASE="https://github.com/${REPO}/releases/download/v${VERSION}" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT - declare -A FILES=( + declare -A COMPATIBILITY_ALIASES=( ["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg" ["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg" ["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe" @@ -547,13 +691,16 @@ jobs: ["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman" ) - # Download versioned files and re-upload with stable names - for STABLE_NAME in "${!FILES[@]}"; do - VERSIONED_NAME="${FILES[$STABLE_NAME]}" - echo "Downloading ${VERSIONED_NAME} -> ${STABLE_NAME}" - curl -fSL -o "${TMP_DIR}/${VERSIONED_NAME}" "${DOWNLOAD_BASE}/${VERSIONED_NAME}" - cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${STABLE_NAME}" - gh release upload "v${VERSION}" "${TMP_DIR}/${STABLE_NAME}" --repo "$REPO" --clobber + for ALIAS_NAME in "${!COMPATIBILITY_ALIASES[@]}"; do + VERSIONED_NAME="${COMPATIBILITY_ALIASES[$ALIAS_NAME]}" + echo "Uploading compatibility alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}" + gh release download "${TAG}" \ + --repo "$REPO" \ + --pattern "${VERSIONED_NAME}" \ + --dir "$TMP_DIR" \ + --clobber + cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${ALIAS_NAME}" + gh release upload "${TAG}" "${TMP_DIR}/${ALIAS_NAME}" --repo "$REPO" --clobber done - name: Publish canonical updater metadata @@ -561,8 +708,8 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - VERSION="${GITHUB_REF#refs/tags/v}" - TAG="v${VERSION}" + VERSION="${RELEASE_TAG#v}" + TAG="${RELEASE_TAG}" REPO="${GITHUB_REPOSITORY}" RELEASE_DATE="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" TMP_DIR="$(mktemp -d)" @@ -578,35 +725,41 @@ jobs: download_asset() { local name="$1" - curl -fSL -o "$name" "https://github.com/${REPO}/releases/download/${TAG}/${name}" + gh release download "${TAG}" \ + --repo "${REPO}" \ + --pattern "$name" \ + --dir . \ + --clobber } # Canonical Windows feed - download_asset "Claude-Agent-Teams-UI-Setup.exe" - WIN_SHA="$(sha512_base64 "Claude-Agent-Teams-UI-Setup.exe")" - WIN_SIZE="$(file_size "Claude-Agent-Teams-UI-Setup.exe")" + WIN_ASSET="Agent.Teams.AI.Setup.${VERSION}.exe" + download_asset "${WIN_ASSET}" + WIN_SHA="$(sha512_base64 "${WIN_ASSET}")" + WIN_SIZE="$(file_size "${WIN_ASSET}")" cat > latest.yml < latest-linux.yml < diff --git a/docs/team-management/assets/team-member-runtime-telemetry-variant-b-reference.png b/docs/team-management/assets/team-member-runtime-telemetry-variant-b-reference.png new file mode 100644 index 00000000..29145dfe Binary files /dev/null and b/docs/team-management/assets/team-member-runtime-telemetry-variant-b-reference.png differ diff --git a/docs/team-management/member-runtime-telemetry-reference.md b/docs/team-management/member-runtime-telemetry-reference.md new file mode 100644 index 00000000..02302125 --- /dev/null +++ b/docs/team-management/member-runtime-telemetry-reference.md @@ -0,0 +1,12 @@ +# Member Runtime Telemetry Reference + +Design reference for participant-card runtime telemetry: + +![Variant B reference](assets/team-member-runtime-telemetry-variant-b-reference.png) + +Chosen direction: Variant B. + +- Memory history renders as a subtle green filled micro-area at the bottom of each member row. +- CPU history renders as a thin blue line immediately above the memory band. +- The strip stays behind row content and uses low contrast so member names, model labels, task pills, and icons remain readable. +- Runtime history is owned by the main process and attached to `TeamAgentRuntimeSnapshot`, not accumulated in React components. diff --git a/docs/team-management/opencode-snapshot-first-proof-upgrade-plan.md b/docs/team-management/opencode-snapshot-first-proof-upgrade-plan.md new file mode 100644 index 00000000..75631a42 --- /dev/null +++ b/docs/team-management/opencode-snapshot-first-proof-upgrade-plan.md @@ -0,0 +1,3068 @@ +# OpenCode Snapshot-First Proof Upgrade Plan + +## Goal + +Reduce false or avoidable OpenCode review warnings for new tasks by upgrading +metadata-only OpenCode `edit`, `write`, and `apply_patch` changes to verified +full-text before/after changes when, and only when, existing OpenCode snapshot +evidence proves the exact file state transition. + +The implementation must be fail-closed: + +- If proof is complete, store full before/after content and remove manual-only warnings. +- If proof is incomplete, ambiguous, too large, binary, outside scope, or unavailable, keep the current warning. +- Never use current disk content as proof for historical before/after. +- Never broaden attribution outside strict delivery context. + +## Non-goals + +- Do not change Codex or Anthropic task extraction. +- Do not change generic review UI semantics. +- Do not infer diffs from current disk. +- Do not scan unrelated OpenCode sessions. +- Do not increase OpenCode snapshot file size limits as part of this work. +- Do not retroactively "fix" old tasks unless existing ledger backfill has strict delivery and snapshot evidence. + +## Current System Facts + +Desktop repo: + +- `ChangeExtractorService` requests OpenCode ledger backfill only when delivery context is available. +- Backfill goes through `OpenCodeReadinessBridge.backfillOpenCodeTaskLedger`. +- Imported events already support full before/after content and metadata-only fallbacks. + +Orchestrator repo: + +- `OpenCodeProfileManager.buildManagedConfig()` already sets `snapshot: true`. +- `OpenCodeLedgerBridgeService.backfill()` reconstructs toolpart changes, then calls `OpenCodeChangeEvidenceEnricher.enrich()`. +- `OpenCodeOfflineSessionReader` reads OpenCode SQLite history in read-only mode and extracts snapshot windows. +- `OpenCodeSnapshotEvidenceProviderService` reads before/after snapshot file contents with strict limits. +- `OpenCodeToolpartChangeReconstructor` already creates exact `toolpart-chain` changes when it has a known baseline. +- `OpenCodeChangeEvidenceEnricher` already upgrades some metadata-only changes through snapshot or inverse chain proof. + +This plan should strengthen the existing evidence path rather than introduce a +new capture subsystem. + +## Risk Estimate + +Recommended implementation: + +- Functional bug risk: 3/10. +- Performance regression risk: 2/10. +- Data safety risk: 2/10. +- Complexity: 7/10. +- Approximate runtime change size: 220-450 LOC. +- Approximate total change size with tests and diagnostics: 450-900 LOC. + +The low data safety risk depends on preserving fail-closed behavior. If any +step starts accepting guesses as proof, data safety risk becomes 7/10 or worse. + +## Hard Safety Invariants + +These invariants are more important than reducing warnings. + +1. A full-text upgrade must be tied to one task, one member, one OpenCode + session, one delivery record, one assistant message, one toolpart, and one + snapshot window. +2. The upgrade must be local to OpenCode. Codex, Anthropic, generic task-log + parsing, and non-OpenCode review flows must not change. +3. `strict-delivery` is required for every snapshot-based full-text upgrade. + Compatible attribution may still import metadata-only events, but it must not + produce auto-safe before/after content. +4. Current disk content is never historical evidence. It can be displayed as + read-only context by the desktop, but it cannot remove a warning or enable + safe reject. +5. Hash-only evidence is not full-text evidence. A hash can verify text that was + already read from a trusted snapshot, but a hash alone is not enough. +6. Empty string is valid full text. `null` and `undefined` mean unavailable. +7. Large, binary, truncated, path-unsafe, or schema-unsupported content stays + metadata-only. +8. A failed upgrade must preserve the original change event shape as much as + possible. It may add diagnostics, but it must not remove warnings or mutate + operation/confidence. +9. Imported event idempotency must remain based on existing source import keys. + The upgrade must not create duplicate events for the same toolpart/path. +10. Any multi-change path chain must be all-or-nothing for unresolved changes in + that path/window. Partial upgrades are allowed only for changes that were + already independently exact before the chain attempt. +11. The implementation must never make a previously non-rejectable change + rejectable unless both `beforeContent` and the target after/absence state + are proven from the same trusted historical evidence path. +12. Diagnostics are allowed to become more detailed. They are not allowed to be + used as a substitute for proof. + +## Things That Are Explicitly Not Proof + +These signals can be useful diagnostics, but they must not remove warnings or +enable safe reject by themselves: + +- Current disk content matching an expected hash. +- Current disk content matching `newString`. +- A file path appearing in OpenCode tool metadata. +- A file path appearing in a snapshot diff without readable before/after text. +- A before/after hash without the corresponding text blob. +- An OpenCode tool status of `completed`. +- The absence of an error in the toolpart. +- The task title mentioning the same directory. +- A member name matching the expected teammate. +- A session id matching but no strict delivery record. +- A snapshot window in the same session but a different assistant message. +- A snapshot window that overlaps several toolparts ambiguously. +- A successful manual UI render of current disk preview. + +If implementation pressure makes any of these tempting, stop and keep the +warning. + +## Threat Model + +The feature is not security-sensitive in the network sense, but it is +data-safety-sensitive. The main threat is a false full-text proof that enables +safe reject/apply for the wrong historical state. + +Bug classes to defend against: + +1. Cross-task contamination: + - A file change from task A appears in task B review. + - Main defense: strict delivery, canonical task id, source message/window + matching, real-data smoke. +2. Cross-member contamination: + - A teammate using the same OpenCode profile is attributed to another member. + - Main defense: delivery record member/lane/session matching. +3. Cross-window contamination: + - A toolpart is matched to the wrong snapshot window in the same message. + - Main defense: exactly-one window matching and order tests. +4. False baseline: + - Current disk or hash-only evidence is treated as historical before text. + - Main defense: "not proof" list and code review checklist. +5. Unsafe warning removal: + - UI stops warning about a file that is still manual-only. + - Main defense: central warning predicate and negative warning tests. +6. Duplicate imported events: + - The same source toolpart appears twice after re-backfill. + - Main defense: source-key idempotency audit and repeated-backfill tests. +7. Silent performance regression: + - Snapshot proof reads too many blobs or times out often. + - Main defense: proof-needed filtering, existing limits, timing counters. +8. Unsupported upstream shape: + - OpenCode changes SQLite/snapshot schema and our parser guesses. + - Main defense: shape fingerprint, unsupported fallback, abort condition. + +For every bug class above, the implementation needs at least one negative test +or real-data smoke assertion. + +## Pre-Implementation Audit Checklist + +Before writing runtime code, answer these questions from the current codebase: + +1. Does the task-change ledger importer update, replace, supersede, or append + events with the same `sourceImportKey`? +2. Does the desktop review bundle dedupe by source key, file path, event id, or + a computed change id? +3. Which exact helper decides whether a file is rejectable? +4. Which warnings are currently surfaced in `TeamChangesSection` versus the + full review dialog? +5. Does the OpenCode backfill cache hide an upgraded result for up to 60 seconds + after a first metadata-only result? +6. Does `materializeMetadataOnlyChanges` preserve `evidenceProof`, + `snapshotId`, `snapshotSource`, and warnings exactly? +7. Can the snapshot provider return `beforeState`/`afterState` with hashes but + no text, and how is that serialized into task-change events? +8. Are OpenCode snapshot windows always message-local in current real data? +9. Are there real examples where a single toolpart touches more than one file? +10. Are there real examples where `apply_patch` contains rename or mode-only + changes? +11. Does the snapshot provider ever return duplicate file entries for the same + normalized path? +12. Does the task-change worker cache bundle results independently from the + OpenCode backfill cache? +13. Are task-level warnings derived only from imported events, or can they come + from boundary parsing separately? +14. Does a safe reject require `beforeContent`, or can `beforeState.exists === false` + plus `afterContent` be enough for creates? +15. Is there any existing telemetry/log sink where structured counters can be + emitted without leaking file contents? + +If any answer is unknown, add a focused diagnostic or unit test before changing +behavior. Do not use implementation guesses for these contracts. + +## Decision Gates + +These gates must be passed in order. Do not skip gates to get fewer warnings +faster. + +| Gate | Required evidence | If not met | +| --- | --- | --- | +| G0 contract audit | importer, bundle, rejectability, cache behavior known | no runtime change | +| G1 diagnostics-only | new diagnostics pass tests with no behavior change | fix diagnostics first | +| G2 shadow proof | proof computes stats but imports original changes | keep behavior disabled | +| G3 single-change proof | positive and negative single-change tests pass | keep apply disabled | +| G4 real-data single-change smoke | OpenCode improves or stays same, non-OpenCode unchanged | do not enable default | +| G5 multi-change proof | all chain tests pass, no ambiguous branch accepted | keep `full` unavailable | +| G6 real-data full smoke | no cross-task leakage, budgets pass | keep default `single-change` | +| G7 rollback check | `OPENCODE_SNAPSHOT_PROOF_UPGRADE=off` restores old behavior | do not ship | + +The implementation should be easy to stop after G4. Single-change mode is a +valid ship point; `full` mode is optional. + +## Known Unknowns That Block Full Mode + +`full` mode must stay disabled if any of these are still unknown: + +- Whether importer supersedes or appends duplicate `sourceImportKey` events. +- Whether real OpenCode data has nested or overlapping snapshot windows. +- Whether real OpenCode `apply_patch` parts include rename, chmod, or binary + patch shapes. +- Whether multi-change same-path chains occur often enough to justify the risk. +- Whether review bundle dedupe can handle upgraded old events without duplicate + rows. +- Whether snapshot proof stats can be collected without logging sensitive + content. + +Unknowns do not block diagnostics or single-change mode. They block `full` mode. + +## Assumption Ledger + +Keep an explicit ledger of assumptions. Each assumption needs a validation path +and a fallback. Do not leave assumptions implicit in implementation code. + +| Assumption | Validation | Fallback if false | +| --- | --- | --- | +| OpenCode snapshot windows are message-local | unit fixture and real-data diagnostics | metadata-only fallback | +| Source import keys are stable across re-backfill | repeated-backfill test | new-imports-only, no old rewrite | +| Review bundle dedupes safely | Phase 0 audit and bridge test | do not upgrade old events | +| Empty string survives materialization | serialization test | do not upgrade empty files | +| Existing reject helper checks current disk | desktop contract test | fix shared helper before enabling | +| Snapshot store objects remain readable long enough | retention fixture and diagnostics | metadata-only fallback | +| Part ordering is stable enough for chains | ordering unit tests | disable `full` | +| Warning predicates are complete | unit tests naming every removed warning | preserve warning | +| Stats can be emitted without content | log review and tests | disable stats or redact harder | +| Non-OpenCode fingerprints stay identical | real-data mode comparison | keep apply modes disabled | + +If an assumption has no validation path, it should be moved to "Known Unknowns" +and block `full` mode. + +## Capability And Version Gates + +Do not assume that `snapshot: true` in managed OpenCode config means snapshot +evidence is usable for every session. Treat snapshot proof as a runtime +capability that must be observed for the specific session being backfilled. + +Required capability checks: + +- OpenCode SQLite schema is supported. +- Session identity includes project id, directory, worktree, and git VCS. +- Session worktree matches the expected workspace root. +- Snapshot windows are present and paired. +- Snapshot git store reader reports the expected shape fingerprint. +- Snapshot file evidence can be read under the existing limits. +- The proof path sees the same normalized relative path in reconstruction and + snapshot evidence. + +Suggested result type: + +```ts +type SnapshotProofCapability = + | { + supported: true + shapeFingerprint: string + sessionId: string + projectId: string + } + | { + supported: false + code: + | 'sqlite-schema-unsupported' + | 'session-identity-missing' + | 'workspace-mismatch' + | 'snapshot-window-missing' + | 'snapshot-store-unsupported' + | 'snapshot-store-missing' + diagnostics: string[] + } +``` + +Rules: + +- Unsupported capability returns metadata-only fallback. +- Unknown capability returns metadata-only fallback. +- Capability diagnostics may be emitted in `shadow`. +- Capability success alone is not proof. It only allows proof attempts. + +## Mode Behavior Matrix + +The mode must determine both proof computation and proof application. + +| Mode | Compute proof? | Apply proof? | Import changed events? | Intended use | +| --- | --- | --- | --- | --- | +| `off` | no | no | no | rollback and baseline comparison | +| `shadow` | yes | no | no | validate proof quality and performance | +| `single-change` | yes | only one unresolved change per path/window | yes | safe first rollout | +| `full` | yes | one-change and verified chains | yes | optional later rollout | + +If implementation makes `shadow` import different events from `off`, it is a +bug. If implementation makes `off` compute snapshot proof, it is a performance +bug. + +## Minimum Safe Scope + +The first behavior-changing implementation should intentionally support less +than the full theoretical feature. + +Allowed in first `single-change` apply mode: + +- OpenCode only. +- `strict-delivery` only. +- One unresolved change for one normalized path inside one snapshot window. +- Text files within existing size limits. +- `write` create when before absence and after text are proven. +- `write` modify when before text, after text, and toolpart after content agree. +- `edit` modify when `oldString` occurs exactly once and produces snapshot after. +- delete when before text and after absence are proven. + +Explicitly excluded from first apply mode: + +- Multi-change same-path chains. +- `apply_patch` without parsed hunks. +- rename, chmod, binary patch, submodule, and mode-only changes. +- Any case requiring current disk as evidence. +- Any case requiring line-ending normalization. +- Any case where snapshot evidence exists but operation semantics are unclear. +- Any old metadata-only event rewrite unless source-key supersede is proven. + +This scope is deliberately conservative. The goal is to prove the pipeline, not +to maximize warning reduction in the first implementation. + +## Lowest-Confidence Areas And Mitigations + +The implementation should explicitly address the areas below because they are +where mistakes are most likely. + +### Snapshot Window Matching + +Risk: OpenCode history can contain several `step-start` and `step-finish` +records in the same assistant message. Incorrect ordering could attach a +toolpart to the wrong snapshot pair. + +Mitigation: + +- Keep the existing requirement that a toolpart must match exactly one window. +- Keep message-local matching. Do not match a toolpart to a window from another + assistant message. +- Add tests where a toolpart is before the first window, after the last window, + and inside two overlapping windows. +- If window order cannot be proven from `rawParts`, skip the upgrade. + +### Multi-Change Chains + +Risk: several edits to the same file can produce the same final content through +more than one path. This is the easiest place to create a convincing but wrong +diff. + +Mitigation: + +- Implement single-change upgrades first. +- Gate multi-change chain upgrades behind a narrow helper and dense tests. +- Do not allow a `write` in the middle of a reverse chain unless both sides of + that write are independently proven. +- Abort the whole path/window chain on the first ambiguous step. +- Add a kill switch that can disable multi-change upgrades while leaving + single-change upgrades enabled. + +### Warning Removal + +Risk: broad substring filtering can hide warnings that still matter, especially +task-boundary or attribution warnings. + +Mitigation: + +- Do not remove warnings by broad terms like `manual-only` alone. +- Centralize warning predicates and match only known OpenCode baseline/content + warning messages. +- Preserve all warnings that mention attribution, delivery, boundary, + confidence, path scope, binary, too-large, truncated, or unavailable snapshot + content. +- Add tests where a warning contains `manual-only` but is unrelated to baseline + proof. + +### Snapshot Shape Stability + +Risk: OpenCode can change SQLite or snapshot git-store shape. A shape change +could make old assumptions invalid. + +Mitigation: + +- Keep `snapshotShapeFingerprint` checks visible in diagnostics. +- Treat unknown or unsupported shapes as metadata-only fallback. +- Do not add compatibility shims that guess from partial rows. +- Add an abort condition for a real-data shape mismatch. + +### Snapshot Store Retention + +Risk: OpenCode SQLite can contain snapshot window hashes while the corresponding +git-store objects are missing, pruned, moved, or unreadable. The history then +looks promising but cannot prove full text. + +Mitigation: + +- Treat missing snapshot objects as metadata-only fallback. +- Keep a distinct diagnostic for missing store object versus unsupported shape. +- Do not retry by reading current disk. +- Do not reconstruct from only one side of the snapshot pair. +- Add a fixture where the window exists but object read fails. + +### Performance + +Risk: reading snapshot blobs for every task can become expensive on large +sessions. + +Mitigation: + +- Try snapshot proof only for unresolved OpenCode changes in strict delivery. +- Pass only unresolved touched paths to the snapshot reader unless a same-path + chain requires exact already-proven neighbors. +- Keep the current snapshot read limits. +- Add timing diagnostics around snapshot proof attempts. +- Abort rollout if repeated snapshot timeouts appear in smoke data. + +### Existing Ledger Events + +Risk: a task that was previously imported as metadata-only may later be +backfilled with better evidence. If importer semantics are append-only, the UI +could show duplicates or stale warnings. + +Mitigation: + +- Audit importer behavior before enabling upgrades for old data. +- Prefer stable source-key replacement/superseding if already supported. +- If replacement is not supported, limit the behavior change to new backfill + imports and leave old events untouched. +- Add repeated-backfill tests before real-data smoke. + +### Cache Invalidation + +Risk: desktop or worker cache may return an old metadata-only bundle after the +orchestrator has imported stronger evidence, making validation confusing or +causing stale warnings to persist. + +Mitigation: + +- Audit all cache layers in Phase 0. +- Include the OpenCode ledger fingerprint or imported event count in cache + invalidation if an existing mechanism supports it. +- For tests, clear or bypass caches instead of waiting for TTLs. +- Do not add broad cache busting for all teams. Keep invalidation scoped to the + requested team/task. + +### Partial Success Semantics + +Risk: one file in a task upgrades while another remains metadata-only. Bulk +review actions might accidentally assume the task is fully safe. + +Mitigation: + +- Keep rejectability file-level. +- Keep task-level warnings if any file remains manual-only or if boundaries are + uncertain. +- Add a mixed-task desktop test. + +## Feature Flag And Rollback + +Add a runtime guard before changing behavior: + +```ts +type SnapshotProofUpgradeMode = 'off' | 'shadow' | 'single-change' | 'full' + +function getSnapshotProofUpgradeMode(env: NodeJS.ProcessEnv): SnapshotProofUpgradeMode { + const raw = env.OPENCODE_SNAPSHOT_PROOF_UPGRADE + if (raw === '0' || raw === 'off') { + return 'off' + } + if (raw === 'shadow') { + return 'shadow' + } + if (raw === 'full') { + return 'full' + } + if (raw === 'single-change') { + return 'single-change' + } + return 'shadow' +} +``` + +Recommended rollout: + +- Default to `shadow` during development and first smoke validation. +- Move to `single-change` only after shadow stats show expected upgrades with + no behavior changes. +- Move to `full` only after multi-change chain tests and real-data smoke pass. +- Keep `off` available as an emergency rollback path. +- If `full` later becomes the default, that should be a separate rollout change + after the implementation has passed real-data smoke in explicit `full` mode. + +If the project already has a central feature-flag/env helper for OpenCode +runtime behavior, use that instead of adding a new ad-hoc parser. + +`shadow` mode is intentionally different from `off`: + +- `off` does not attempt proof. +- `shadow` attempts proof and records stats/diagnostics, but returns the + original changes to the importer. +- `single-change` applies only one-change path/window upgrades. +- `full` applies single-change and multi-change chain upgrades. + +This gives a low-risk way to validate proof quality and performance on real +data before changing review safety. + +## Architecture + +Use this pipeline: + +```text +OpenCode SQLite history + -> toolpart reconstruction + -> strict delivery attribution + -> snapshot window grouping + -> snapshot file read with limits + -> proof upgrade per path + -> validate candidate batch + -> import task-change events +``` + +The upgrade belongs in the orchestrator evidence layer, primarily around: + +- `OpenCodeChangeEvidenceEnricher.ts` +- `OpenCodeSnapshotEvidenceProvider.ts` +- `OpenCodeToolpartChangeReconstructor.ts` only if a small helper or extra metadata is needed +- tests near existing OpenCode evidence and ledger bridge tests + +Avoid touching desktop review UI for the proof itself. The desktop should only +benefit from better imported event content. + +## Composite Identity Contract + +Every full-text proof must be anchored to a composite identity. Do not rely on +any single field alone. + +Required identity dimensions: + +```ts +type SnapshotProofIdentity = { + teamName: string + taskId: string + memberName: string + laneId?: string + sessionId: string + parentUserMessageId?: string + assistantMessageId: string + sourceMessageId: string + sourcePartId: string + toolUseId: string + relativePath: string + snapshotWindowId: string + fromSnapshot: string + toSnapshot: string +} +``` + +Rules: + +- `taskId` must be canonical, not display-only. +- `memberName`, `laneId`, and `sessionId` must come from strict delivery or + already trusted session records. +- `sourceMessageId` must match the snapshot window message id. +- `sourcePartId` must be inside the matched window according to the same + message's part order. +- `relativePath` must be normalized through the existing OpenCode path helpers. +- `fromSnapshot` and `toSnapshot` must be the exact pair used to read file + evidence. + +If any identity dimension is missing, the default is `metadata-only-fallback`. + +## Ordering Contract + +Same-path chains are safe only if toolpart order is stable and proven. Use the +existing ordering data from OpenCode SQLite. Do not introduce a new sort. + +Preferred order keys, in priority order: + +1. `messageTimeCreated` +2. `messageIdSort` +3. `messagePartOrder` +4. `partId` + +Rules: + +- Do not sort only by `partId`. +- Do not sort only by timestamp. +- Do not merge parts from different `sourceMessageId` values into one chain. +- If two parts have indistinguishable order, do not upgrade the chain. +- If raw part order is unavailable, single-change upgrade may still work, but + multi-change mode must skip. + +Example guard: + +```ts +function hasStablePartOrder(parts: SourcePartSortKey[]): boolean { + const seen = new Set() + for (const part of parts) { + const key = [ + part.messageTimeCreated, + part.messageIdSort, + part.messagePartOrder, + part.partId, + ].join('\0') + if (seen.has(key)) { + return false + } + seen.add(key) + } + return true +} +``` + +If the real symbol names differ, keep the same invariant. + +## Cross-Repo Contract Boundaries + +This feature crosses the desktop repo and the orchestrator repo. Keep the +contract explicit. + +Desktop responsibilities: + +- Request OpenCode backfill only when delivery context exists. +- Keep cache/in-flight dedupe behavior. +- Render full-text events as diffs. +- Render metadata-only events as manual-only warnings. +- Use existing rejectability checks. Do not special-case OpenCode snapshot + events in the UI unless a rendering bug is found. + +Orchestrator responsibilities: + +- Read OpenCode history and snapshot evidence. +- Decide whether proof is strong enough to materialize before/after text. +- Preserve strict delivery attribution. +- Preserve source import keys. +- Emit diagnostics explaining why upgrades were skipped. + +Shared contract: + +```ts +type ReviewSafetyContract = { + sourceImportKey: string + evidenceProof: OpenCodeEvidenceProof + beforeContent: string | null + afterContent: string | null + beforeState?: { exists?: boolean; sha256?: string; sizeBytes?: number; unavailableReason?: string } + afterState?: { exists?: boolean; sha256?: string; sizeBytes?: number; unavailableReason?: string } + warnings?: string[] +} +``` + +Safe reject requires a proven historical baseline: + +```ts +function hasSafeHistoricalBaseline(change: ReviewSafetyContract): boolean { + if (change.beforeContent !== null) { + return true + } + return change.beforeState?.exists === false && change.afterContent !== null +} +``` + +The exact desktop helper may have a different name. The invariant should match +this contract. + +## Apply/Reject Execution Safety Contract + +Snapshot proof can make a review event eligible for normal diff rendering and +safe reject consideration. It must not bypass current worktree conflict checks. + +Review safety and execution safety are different: + +- Review safety answers: "Do we know the historical before/after for this + change?" +- Execution safety answers: "Can we apply or reject this change against the + user's current disk state right now?" + +This feature only upgrades review safety. It must not weaken execution safety. + +Required rules: + +- Rejecting a modify still requires the current file to match the expected after + state, or whatever stricter existing conflict check is already used. +- Rejecting a create still requires the current file to match the created after + state before deletion. +- Rejecting a delete still requires the current absence/after state to match the + expected deleted state before restoring before content. +- Accepting an OpenCode change must not overwrite unrelated current disk edits. +- Bulk `Reject All` must keep per-file conflict checks and skip unsafe files. +- Current disk mismatch should produce a conflict/manual warning, not a proof + downgrade. + +Suggested predicate split: + +```ts +function isReviewSafe(change: ReviewSafetyContract): boolean { + return isSnapshotReviewSafe(change) +} + +function canExecuteReject(input: { + change: ReviewSafetyContract + currentDiskState: { exists: boolean; sha256?: string } +}): boolean { + if (!isReviewSafe(input.change)) { + return false + } + // Use the existing project helper here. This sketch only documents that + // execution safety is a separate check from proof safety. + return currentDiskMatchesExpectedAfterState(input.change, input.currentDiskState) +} +``` + +Do not implement `currentDiskMatchesExpectedAfterState` ad hoc if the project +already has a conflict/rejectability helper. This plan requires preserving that +existing behavior. + +## Data Model Contract + +Do not introduce a new task-change event shape unless absolutely necessary. +Prefer filling existing fields: + +```ts +type UpgradedOpenCodeChangeContract = { + sourceTool: 'write' | 'edit' | 'apply_patch' | 'snapshot_patch' + sourceImportKey: string + evidenceProof: 'opencode-snapshot' | 'inverse-edit-chain' | 'inverse-apply-patch-chain' | 'toolpart-chain' + confidence: 'high' | 'exact' + beforeContent: string | null + afterContent: string | null + beforeState: { + exists?: boolean + sha256?: string + sizeBytes?: number + unavailableReason?: never + } + afterState: { + exists?: boolean + sha256?: string + sizeBytes?: number + unavailableReason?: never + } + snapshotId?: string + snapshotSource?: 'opencode' + warnings: string[] +} +``` + +Important: + +- Upgraded full-text events should not carry `unavailableReason` for the + before/after side they claim to prove. +- Metadata-only events may carry `unavailableReason`, but then they must remain + non-rejectable. +- `confidence: 'high'` is acceptable for snapshot proof. Use `exact` only for + truly exact toolpart chains that already have local full text. +- `snapshotId` is useful provenance, but it is not required for safety if the + proof was otherwise validated. Missing `snapshotId` should be diagnostic. + +## Storage And Memory Contract + +The feature must not create a second blob storage path or keep large full-text +content in memory longer than the existing ledger import requires. + +Rules: + +- Reuse the existing task-change ledger content storage. +- Do not duplicate before/after text in diagnostics, stats, or cache keys. +- Do not add per-team global caches of snapshot file content. +- Do not store both snapshot raw blobs and task-change blobs unless the existing + snapshot reader already does that internally. +- Apply the per-file and total byte limits before materializing upgraded events. +- If a file exceeds the limit, store metadata-only state with a reason. +- If many small files exceed the total byte budget, skip the excess files as + metadata-only instead of raising the limit. +- Stats should count bytes read and files skipped, but never include content. + +Suggested stats additions: + +```ts +type SnapshotProofStorageStats = { + bytesRead: number + bytesMaterialized: number + skippedByByteLimit: number + skippedByTotalBudget: number +} +``` + +Memory pressure is a reason to keep metadata-only fallback. It is not a reason +to increase limits or stream partial text into a diff. + +## Mutation Rules + +When an upgrade is skipped, only diagnostics may change. The returned +`ReconstructedOpenCodeToolChange` must preserve: + +- `beforeContent` +- `afterContent` +- `beforeState` +- `afterState` +- `operation` +- `confidence` +- `warnings` +- `evidenceProof` +- `sourceImportKey` + +When an upgrade succeeds, only these fields may change: + +- `beforeContent` +- `afterContent` +- `beforeState` +- `afterState` +- `operation`, only when the tool semantics and snapshot operation both prove it +- `confidence` +- `warnings`, only through the central resolved-warning predicate +- `evidenceProof` +- `evidenceDiagnostics` +- `snapshotId` +- `snapshotSource` + +No other fields should be rewritten by the proof upgrade. This reduces +accidental attribution changes. + +## Proof Levels + +Use explicit proof labels and keep their meaning strict. + +```ts +type OpenCodeEvidenceProof = + | 'toolpart-chain' + | 'opencode-snapshot' + | 'inverse-edit-chain' + | 'inverse-apply-patch-chain' + | 'metadata-only-fallback' +``` + +Accepted for auto review: + +- `toolpart-chain` +- `opencode-snapshot` +- `inverse-edit-chain` +- `inverse-apply-patch-chain` + +Not accepted for safe reject/apply: + +- `metadata-only-fallback` +- current disk only +- file path metadata only +- hash without text +- text without matching task/window/path proof + +## Proof Decision Tables + +### Operation State Table + +| Tool | Snapshot before | Snapshot after | Tool fields | Upgrade? | Reason | +| --- | --- | --- | --- | --- | --- | +| `write` create | absent | text | content absent or same as after | yes | create is fully proven | +| `write` modify | text | text | content absent or same as after | yes | before and after are fully proven | +| `write` modify | unavailable | text | any | no | overwrite baseline is unknown | +| `write` modify | text | text | content differs from after | no | toolpart and snapshot disagree | +| `edit` modify | text | text | old/new apply exactly once | yes | edit transition is proven | +| `edit` modify | text | text | old/new ambiguous | no | multiple valid transitions are possible | +| `apply_patch` modify | text | text | hunks verify exactly | yes | patch transition is proven | +| `apply_patch` modify | text | text | hunks missing | maybe | only if the snapshot window has exact single-file proof and no competing changes | +| delete | text | absent | delete operation | yes | delete is fully proven | +| any | binary/large/unavailable | any | any | no | full text is not available | + +### Confidence Table + +| Evidence | Confidence | Safe reject/apply? | +| --- | --- | --- | +| toolpart chain with known previous text | `exact` | yes | +| snapshot before/after with verified transition | `high` | yes | +| inverse edit/apply-patch chain with exact single replacements | `high` | yes | +| snapshot path anchor without verified transition | `medium` | no | +| metadata-only toolpart | `medium` | no | + +Do not upgrade confidence from `medium` to `high` unless safe reject/apply would +also be valid. + +## Proof State Machine + +Implement the upgrade as a state machine, not as scattered conditionals. + +```text +original change + -> not eligible + -> candidate + -> snapshot evidence requested + -> snapshot evidence matched + -> transition verified + -> upgraded change + -> validated import candidate +``` + +Failure from any state returns to the original metadata-only change plus +diagnostics. + +Allowed transitions: + +| From | To | Required condition | +| --- | --- | --- | +| original | not eligible | not OpenCode, exact already, flag off, non-strict delivery | +| original | candidate | OpenCode unresolved change, strict delivery, flag permits | +| candidate | snapshot requested | non-empty touched path set within limits | +| snapshot requested | snapshot matched | exactly one window and one file anchor | +| snapshot matched | transition verified | operation-specific before/after proof succeeds | +| transition verified | upgraded | state hashes match content and warnings stripped safely | +| upgraded | validated import candidate | existing candidate validation accepts it | + +Forbidden transitions: + +- original -> upgraded +- candidate -> upgraded +- snapshot requested -> upgraded +- snapshot matched -> upgraded without operation-specific verification +- skipped -> upgraded + +Suggested type: + +```ts +type ProofState = + | { state: 'not-eligible'; reason: SnapshotUpgradeDiagnosticCode } + | { state: 'candidate'; change: ReconstructedOpenCodeToolChange } + | { state: 'snapshot-matched'; change: ReconstructedOpenCodeToolChange; anchor: SnapshotFileAnchor } + | { state: 'transition-verified'; change: ReconstructedOpenCodeToolChange; before: string | null; after: string | null } + | { state: 'upgraded'; change: ReconstructedOpenCodeToolChange } + | { state: 'skipped'; reason: SnapshotUpgradeDiagnosticCode; original: ReconstructedOpenCodeToolChange } +``` + +The concrete implementation does not have to use this exact union, but it +should preserve the same transitions. + +## Exhaustiveness And Type Safety + +Use exhaustive switches for proof decisions, operation handling, and feature +flag modes. Do not add a permissive `default` branch that silently preserves or +upgrades without naming the case. + +```ts +function assertNever(value: never, context: string): never { + throw new Error(`Unexpected ${context}: ${String(value)}`) +} + +function applyProofDecision( + decision: SnapshotProofDecision, + original: ReconstructedOpenCodeToolChange, +): ReconstructedOpenCodeToolChange { + switch (decision.type) { + case 'upgraded': + return decision.change + case 'skipped': + return original + default: + return assertNever(decision, 'snapshot proof decision') + } +} +``` + +If TypeScript cannot prove exhaustiveness, keep the code more explicit rather +than using casts. A cast in proof code should be treated as a review smell. + +## Default Answers To Uncertainty + +Use these defaults when implementation hits an unclear case: + +| Question | Default | +| --- | --- | +| Is attribution strict enough? | no upgrade | +| Is toolpart order stable? | no multi-change upgrade | +| Does snapshot text prove the operation? | no upgrade | +| Does warning removal feel broad? | preserve warning | +| Is content text or binary? | treat as unavailable | +| Does old event replacement behavior seem unclear? | new imports only | +| Is cache invalidation unclear? | do not rely on cache for proof | +| Does UI need an OpenCode-specific branch? | fix shared helper or stop | +| Is performance impact unclear? | keep flag off or single-change only | + +These defaults are part of the safety design, not temporary indecision. + +## Formal Proof Predicates + +Implement proof decisions through small predicates that can be unit tested +directly. Avoid spreading equivalent checks across several branches. + +```ts +function isReadableFullText(value: string | null | undefined): value is string { + return typeof value === 'string' +} + +function isKnownAbsent(state: { exists?: boolean } | undefined): boolean { + return state?.exists === false +} + +function hasUnavailableReason(state: { unavailableReason?: string } | undefined): boolean { + return typeof state?.unavailableReason === 'string' && state.unavailableReason.length > 0 +} + +function isProvenCreate(change: ReviewSafetyContract): boolean { + return ( + isKnownAbsent(change.beforeState) && + isReadableFullText(change.afterContent) && + !hasUnavailableReason(change.afterState) + ) +} + +function isProvenModify(change: ReviewSafetyContract): boolean { + return ( + isReadableFullText(change.beforeContent) && + isReadableFullText(change.afterContent) && + !hasUnavailableReason(change.beforeState) && + !hasUnavailableReason(change.afterState) + ) +} + +function isProvenDelete(change: ReviewSafetyContract): boolean { + return ( + isReadableFullText(change.beforeContent) && + change.afterState?.exists === false && + !hasUnavailableReason(change.beforeState) + ) +} + +function isSnapshotReviewSafe(change: ReviewSafetyContract): boolean { + return ( + change.evidenceProof === 'opencode-snapshot' || + change.evidenceProof === 'inverse-edit-chain' || + change.evidenceProof === 'inverse-apply-patch-chain' || + change.evidenceProof === 'toolpart-chain' + ) && ( + isProvenCreate(change) || + isProvenModify(change) || + isProvenDelete(change) + ) +} +``` + +The real implementation can use existing helper names, but tests should cover +the predicates above as behavior. In particular, `unavailableReason` on a side +that claims full proof should make the change unsafe. + +## Atomicity And Failure Semantics + +Snapshot proof should behave atomically at three levels. + +Per change: + +- Success returns one upgraded change. +- Failure returns the original change unchanged plus diagnostics. +- No intermediate state should be visible to importer validation. + +Per same-path chain: + +- Success upgrades every unresolved change in the chain. +- Failure upgrades none of the unresolved changes in the chain. +- Already exact changes may remain exact, but they must not be rewritten by the + failed chain attempt. + +Per import batch: + +- Candidate validation runs after proof upgrade. +- If import fails, review safety must not observe a partially imported safe + state. +- Retry uses stable source import keys. + +Implementation pattern: + +```ts +const original = change +const decision = tryUpgradeChange(change) +if (decision.type === 'skipped') { + diagnostics.push(decision.reason) + return original +} +const upgraded = decision.change +if (!isSnapshotReviewSafe(upgraded)) { + diagnostics.push('snapshot-upgrade-skipped/postcondition-failed') + return original +} +return upgraded +``` + +Do not mutate `change` in place before postconditions pass. + +## Postconditions + +Every successful upgrade must satisfy these postconditions: + +```ts +function assertUpgradePostconditions(input: { + original: ReconstructedOpenCodeToolChange + upgraded: ReconstructedOpenCodeToolChange +}): boolean { + const { original, upgraded } = input + return ( + original.sourceImportKey === upgraded.sourceImportKey && + original.taskId === upgraded.taskId && + original.teamName === upgraded.teamName && + original.memberName === upgraded.memberName && + original.sessionId === upgraded.sessionId && + original.sourcePartId === upgraded.sourcePartId && + original.sourceMessageId === upgraded.sourceMessageId && + original.relativePath === upgraded.relativePath && + upgraded.evidenceProof !== 'metadata-only-fallback' && + isSnapshotReviewSafe(upgraded) + ) +} +``` + +If a postcondition fails, keep the original change and emit a diagnostic. A +postcondition failure is a bug in proof logic, not a reason to relax safety. + +## Runtime Assertion Policy + +Assertions should catch programmer errors without making production data unsafe. + +Rules: + +- In tests, postcondition failures should fail loudly. +- In production backfill, postcondition failures should skip the upgrade, + preserve the original metadata-only change, and emit a diagnostic. +- Assertions must never catch an error and continue with upgraded content. +- Assertions must not include file content in thrown messages. +- Assertions should include stable identifiers such as task id, source part id, + source import key, and normalized relative path. + +Suggested pattern: + +```ts +function enforceUpgradePostconditions(input: { + original: ReconstructedOpenCodeToolChange + upgraded: ReconstructedOpenCodeToolChange + diagnostics: string[] +}): ReconstructedOpenCodeToolChange { + if (assertUpgradePostconditions(input)) { + return input.upgraded + } + input.diagnostics.push( + `snapshot-upgrade-skipped/postcondition-failed:${input.original.sourceImportKey}`, + ) + return input.original +} +``` + +Do not use runtime assertions to justify looser proof predicates. Assertions are +a last guard, not the proof itself. + +## Implementation Phases + +### Phase 0 - Audit Contracts Before Behavior Changes + +This phase should be completed before any runtime behavior change. + +Audit: + +- Ledger import behavior for duplicate `sourceImportKey`. +- Review bundle dedupe behavior. +- Existing rejectability helper. +- Existing OpenCode backfill cache and in-flight behavior. +- `materializeMetadataOnlyChanges` serialization of proof fields. +- Current real-data snapshot diagnostics for a few OpenCode teams. + +Deliverable: + +```text +Contract audit: +- sourceImportKey duplicate policy: replace | supersede | append | unknown +- review bundle dedupe key: ... +- rejectability helper: ... +- metadata materialization preserves proof fields: yes | no +- observed snapshot shape fingerprint: ... +- can proceed to Phase 1: yes | no +``` + +If any field is `unknown`, do not proceed to behavior changes. + +### Phase 1 - Add Targeted Diagnostics + +Add diagnostics that explain why a metadata-only change was not upgraded. +This makes real-data validation much easier. + +Examples: + +- `snapshot-upgrade-skipped/no-window` +- `snapshot-upgrade-skipped/ambiguous-window` +- `snapshot-upgrade-skipped/no-file-anchor` +- `snapshot-upgrade-skipped/binary` +- `snapshot-upgrade-skipped/too-large` +- `snapshot-upgrade-skipped/path-chain-ambiguous` +- `snapshot-upgrade-skipped/toolpart-after-mismatch` +- `snapshot-upgrade-skipped/current-disk-not-proof` +- `snapshot-upgrade-skipped/strict-delivery-required` +- `snapshot-upgrade-skipped/unsupported-snapshot-shape` +- `snapshot-upgrade-skipped/warning-preserved` +- `snapshot-upgrade-skipped/feature-flag-off` + +These diagnostics should not be user-noisy by default, but they should be +available in backfill result diagnostics and tests. + +Diagnostics should be structured internally even if the public result remains a +string array: + +```ts +type SnapshotUpgradeDiagnosticCode = + | 'snapshot-upgrade-skipped/no-window' + | 'snapshot-upgrade-skipped/ambiguous-window' + | 'snapshot-upgrade-skipped/no-file-anchor' + | 'snapshot-upgrade-skipped/operation-mismatch' + | 'snapshot-upgrade-skipped/toolpart-after-mismatch' + | 'snapshot-upgrade-skipped/path-chain-ambiguous' + | 'snapshot-upgrade-skipped/strict-delivery-required' + | 'snapshot-upgrade-skipped/unsupported-snapshot-shape' + +function pushSnapshotDiagnostic( + diagnostics: string[], + code: SnapshotUpgradeDiagnosticCode, + detail: string, +): void { + diagnostics.push(`${code}: ${detail}`) +} +``` + +Using a small typed union makes it harder to accidentally invent inconsistent +diagnostics throughout the proof code. + +### Phase 2 - Make Upgrade Eligibility Explicit + +Add a helper that decides whether a change needs snapshot proof. It should skip +already exact full-text changes. + +```ts +function needsSnapshotProof(change: ReconstructedOpenCodeToolChange): boolean { + if (change.evidenceProof === 'toolpart-chain') { + return false + } + if (change.beforeContent !== null && change.afterContent !== null) { + return false + } + if (change.beforeState?.exists === false && change.afterContent !== null) { + return false + } + if (change.beforeContent !== null && change.afterState?.exists === false) { + return false + } + return ( + change.sourceTool === 'write' || + change.sourceTool === 'edit' || + change.sourceTool === 'apply_patch' || + change.sourceTool === 'snapshot_patch' + ) +} +``` + +Use this helper before expensive snapshot work where possible: + +```ts +const changesNeedingProof = params.changes.filter(needsSnapshotProof) +if (changesNeedingProof.length === 0) { + return result +} +``` + +Important: this helper should reduce work, not reduce safety. If in doubt, +include a change in snapshot proof attempt and let the proof logic reject it. + +Add a second helper for safety eligibility. It must be stricter than +`needsSnapshotProof`. + +```ts +function mayUseSnapshotProof(input: { + attributionMode: OpenCodeLedgerAttributionMode + change: ReconstructedOpenCodeToolChange + mode: SnapshotProofUpgradeMode +}): boolean { + if (input.mode === 'off') { + return false + } + if (input.attributionMode !== 'strict-delivery') { + return false + } + if (!needsSnapshotProof(input.change)) { + return false + } + return input.change.attributionMethod === 'delivery-ledger-taskrefs' +} +``` + +This keeps "should we spend time trying?" separate from "is this proof allowed +to affect review safety?". + +Add a third helper for apply eligibility. `shadow` may compute proof, but must +not apply it. + +```ts +function mayApplySnapshotProof(input: { + mode: SnapshotProofUpgradeMode + changeCountForPathWindow: number +}): boolean { + if (input.mode === 'off' || input.mode === 'shadow') { + return false + } + if (input.mode === 'single-change') { + return input.changeCountForPathWindow === 1 + } + return input.mode === 'full' +} +``` + +The call site should look structurally like this: + +```ts +const decision = tryComputeSnapshotProof(change) +stats.record(decision) +if (!mayApplySnapshotProof({ mode, changeCountForPathWindow })) { + return originalChange +} +return decision.type === 'upgraded' ? decision.change : originalChange +``` + +This prevents diagnostics-only validation from accidentally changing imported +review events. + +Also make the final proof decision return a typed result instead of a nullable +change. Nullable returns tend to hide why an upgrade failed. + +```ts +type SnapshotProofDecision = + | { + type: 'upgraded' + change: ReconstructedOpenCodeToolChange + proof: Exclude + } + | { + type: 'skipped' + reason: SnapshotUpgradeDiagnosticCode + preserveOriginal: true + } + +function preserveOriginal( + reason: SnapshotUpgradeDiagnosticCode, +): SnapshotProofDecision { + return { type: 'skipped', reason, preserveOriginal: true } +} +``` + +Callers should be forced to handle both branches. A skipped decision must return +the original change unchanged except for diagnostics collected outside the +change object. + +### Phase 3 - Strengthen Snapshot Anchor Matching + +Snapshot anchors should be accepted only when all these conditions hold: + +1. The change belongs to a strict delivery session. +2. The source toolpart belongs to exactly one snapshot window. +3. The snapshot window belongs to the same OpenCode message. +4. The normalized touched path is inside the session worktree. +5. The snapshot reader returns an anchor for the exact relative path. +6. Text content is full text, not binary, and within existing limits. +7. The file operation is compatible with the tool operation. + +Add a small validation helper: + +```ts +type SnapshotAnchorValidation = + | { ok: true } + | { ok: false; reason: string } + +function validateSnapshotAnchorForChange(input: { + change: ReconstructedOpenCodeToolChange + anchor: SnapshotFileAnchor | undefined +}): SnapshotAnchorValidation { + const { change, anchor } = input + if (!anchor) { + return { ok: false, reason: 'snapshot-upgrade-skipped/no-file-anchor' } + } + + if (change.operation === 'create' && anchor.operation !== 'create') { + return { ok: false, reason: 'snapshot-upgrade-skipped/operation-mismatch' } + } + + if (change.operation === 'delete' && anchor.operation !== 'delete') { + return { ok: false, reason: 'snapshot-upgrade-skipped/operation-mismatch' } + } + + if (change.operation === 'modify' && anchor.operation === 'create') { + return { ok: false, reason: 'snapshot-upgrade-skipped/operation-mismatch' } + } + + return { ok: true } +} +``` + +Do not rely only on operation matching. It is a gate, not proof. + +The validation helper should also distinguish these concepts: + +- `anchor operation`: what the snapshot diff says happened to the file. +- `tool operation`: what the reconstructed toolpart thinks happened. +- `review operation`: what the imported task-change event will expose. + +If these disagree, do not silently rewrite the operation unless the snapshot +transition and tool semantics both prove the new value. For example, a `write` +with no previous baseline may be reconstructed as `modify`; if snapshot says +`create` and before is absent, it may be upgraded to `create`. A reconstructed +`edit` must not become `create` or `delete`. + +Add source identity checks before path checks: + +```ts +function isSameSourceWindow(input: { + change: ReconstructedOpenCodeToolChange + windowMessageId: string + windowId: string + matchedWindowIds: string[] +}): boolean { + return ( + input.change.sourceMessageId === input.windowMessageId && + input.matchedWindowIds.length === 1 && + input.matchedWindowIds[0] === input.windowId + ) +} +``` + +The exact data shape can differ, but the check must prove message-local and +single-window identity before using the snapshot file anchor. + +### Phase 4 - Upgrade Single-Change Snapshot Proof + +For one change touching a file within a snapshot window, upgrade directly if the +snapshot anchor proves the full transition. + +Rules: + +- `write` create: + - accept when `beforeState.exists === false` and `afterContent` is full text. + - if toolpart content exists, require it to equal snapshot after content. +- `write` modify: + - accept when both snapshot before and after are full text. + - if toolpart content exists, require it to equal snapshot after content. +- `edit` modify: + - accept when both snapshot before and after are full text. + - require applying `oldString -> newString` to before to equal after, unless the edit came from a verified snapshot patch. +- `apply_patch`: + - accept when snapshot before and after are full text. + - if parsed hunks exist, verify before-to-after application or inverse chain. +- delete: + - accept when snapshot before is full text and snapshot after is absent. + +For phase 4, do not support "maybe" `apply_patch` upgrades without parsed hunks. +Keep those for phase 5 or leave them metadata-only. This reduces the first +behavior change to the most provable cases. + +Example helper: + +```ts +function applyEditExactlyOnce(input: { + before: string + oldString: string | undefined + newString: string | undefined +}): string | null { + if ( + typeof input.oldString !== 'string' || + typeof input.newString !== 'string' || + input.oldString === input.newString + ) { + return null + } + if (countOccurrences(input.before, input.oldString) !== 1) { + return null + } + return input.before.replace(input.oldString, input.newString) +} +``` + +Example upgrade: + +```ts +function upgradeEditFromSnapshot(input: { + change: ReconstructedOpenCodeToolChange + anchor: SnapshotFileAnchor +}): ReconstructedOpenCodeToolChange | null { + const before = input.anchor.beforeContent + const after = input.anchor.afterContent + if (typeof before !== 'string' || typeof after !== 'string') { + return null + } + + const applied = applyEditExactlyOnce({ + before, + oldString: input.change.oldString, + newString: input.change.newString, + }) + if (applied !== after) { + return null + } + + return { + ...input.change, + beforeContent: before, + afterContent: after, + beforeState: contentStateForText(before), + afterState: contentStateForText(after), + confidence: 'high', + evidenceProof: 'opencode-snapshot', + snapshotId: input.anchor.snapshotId, + snapshotSource: input.anchor.snapshotId ? 'opencode' : undefined, + warnings: stripManualOnlyWarnings(input.change.warnings, input.anchor.warnings), + } +} +``` + +Add a generic transition verifier so write/edit/apply_patch decisions share the +same state checks: + +```ts +type VerifiedTransition = + | { ok: true; beforeContent: string | null; afterContent: string | null; operation: 'create' | 'modify' | 'delete' } + | { ok: false; reason: SnapshotUpgradeDiagnosticCode } + +function verifySnapshotTransition(input: { + change: ReconstructedOpenCodeToolChange + anchor: SnapshotFileAnchor +}): VerifiedTransition { + const before = input.anchor.beforeContent + const after = input.anchor.afterContent + + if (input.anchor.operation === 'create') { + return typeof after === 'string' + ? { ok: true, beforeContent: null, afterContent: after, operation: 'create' } + : { ok: false, reason: 'snapshot-upgrade-skipped/no-file-anchor' } + } + + if (input.anchor.operation === 'delete') { + return typeof before === 'string' + ? { ok: true, beforeContent: before, afterContent: null, operation: 'delete' } + : { ok: false, reason: 'snapshot-upgrade-skipped/no-file-anchor' } + } + + if (typeof before !== 'string' || typeof after !== 'string') { + return { ok: false, reason: 'snapshot-upgrade-skipped/no-file-anchor' } + } + + return { ok: true, beforeContent: before, afterContent: after, operation: 'modify' } +} +``` + +This function should not be the final proof for `edit` or `apply_patch`. It only +proves that snapshot text exists for the operation state. + +Before returning an upgraded change, verify the emitted states match the emitted +content: + +```ts +function assertStateMatchesContent(input: { + beforeContent: string | null + afterContent: string | null + beforeState: ReconstructedOpenCodeToolChange['beforeState'] + afterState: ReconstructedOpenCodeToolChange['afterState'] +}): boolean { + if (input.beforeContent !== null) { + const expected = contentStateForText(input.beforeContent) + if (input.beforeState?.sha256 !== expected.sha256) { + return false + } + } + if (input.afterContent !== null) { + const expected = contentStateForText(input.afterContent) + if (input.afterState?.sha256 !== expected.sha256) { + return false + } + } + return true +} +``` + +If this assertion fails, keep the original metadata-only change and emit a +diagnostic. Do not import inconsistent state/content. + +### Phase 5 - Upgrade Multi-Change Same-Path Chains + +When several changes touch the same file inside one snapshot window, only +upgrade if the whole chain verifies. + +Algorithm: + +1. Start from snapshot `afterContent`. +2. Walk changes for that path in reverse source order. +3. For each change: + - if it already has full before/after, require its after to equal the cursor. + - for `edit`, reverse `newString -> oldString` exactly once. + - for `apply_patch`, reverse parsed hunks exactly once. + - for `write`, only allow it as the first/oldest operation if snapshot before + matches the previous state or known absent state. +4. If any step is ambiguous, stop and keep all unresolved warnings. +5. If the reverse chain reaches snapshot `beforeContent`, materialize + replacements for every unresolved change in the chain. + +Pseudo-code: + +```ts +function upgradeSamePathChain(input: { + changes: ReconstructedOpenCodeToolChange[] + anchor: SnapshotFileAnchor + diagnostics: string[] +}): Map { + const replacements = new Map() + let cursor = input.anchor.afterContent + + if (typeof cursor !== 'string') { + input.diagnostics.push('snapshot-upgrade-skipped/no-after-anchor') + return replacements + } + + for (let index = input.changes.length - 1; index >= 0; index -= 1) { + const change = input.changes[index] + if (!change) { + continue + } + + const upgraded = reverseOneChangeFromAfter({ change, after: cursor, anchor: input.anchor }) + if (!upgraded) { + input.diagnostics.push(`snapshot-upgrade-skipped/path-chain-ambiguous:${change.relativePath}`) + return new Map() + } + + replacements.set(change.sourceImportKey, upgraded.change) + cursor = upgraded.beforeContent + } + + if (typeof input.anchor.beforeContent === 'string' && cursor !== input.anchor.beforeContent) { + input.diagnostics.push('snapshot-upgrade-skipped/path-chain-boundary-mismatch') + return new Map() + } + + return replacements +} +``` + +This is the highest-risk section. Keep tests dense here. + +If there is any schedule pressure, defer this whole phase. Single-change +upgrades are enough to reduce many warnings and are much less risky. + +Additional multi-change restrictions: + +- Do not cross snapshot-window boundaries. +- Do not cross assistant-message boundaries. +- Do not cross task delivery boundaries. +- Do not mix changes with different `sourceMessageId`. +- Do not mix changes with different normalized `relativePath`. +- Do not include changes whose source import key is missing or duplicated. +- Do not upgrade a chain if any change in the path has an operation that cannot + be reversed from the current cursor. +- Do not upgrade if the final reverse cursor does not exactly equal snapshot + `beforeContent` for modify/delete, or known absence for create. + +Add this explicit guard: + +```ts +function assertSinglePathWindowChain(input: { + changes: ReconstructedOpenCodeToolChange[] +}): boolean { + const relativePaths = new Set(input.changes.map(change => change.relativePath)) + const messageIds = new Set(input.changes.map(change => change.sourceMessageId)) + const importKeys = new Set(input.changes.map(change => change.sourceImportKey)) + return ( + relativePaths.size === 1 && + messageIds.size === 1 && + importKeys.size === input.changes.length + ) +} +``` + +### Phase 6 - Warning Stripping Must Be Conservative + +Only remove warnings that are made false by the new proof. + +Safe to remove after verified before/after: + +- `OpenCode edit was captured without a proven full-text baseline; apply/reject is manual-only.` +- `OpenCode write overwrote an existing file before the bridge had a known baseline; reject is manual-only.` +- `OpenCode apply_patch was captured without full before/after text; review is manual-only.` +- `OpenCode toolpart content was unavailable or too large; review is manual-only.` +- `full review depends on snapshot evidence` + +Do not remove: + +- attribution warnings +- low confidence task boundary warnings +- delivery context warnings +- path outside session directory warnings +- large/binary warnings for other files +- warnings attached to unrelated changes in the same task +- snapshot unavailable warnings attached to the same file +- any warning whose text is not in the known resolved warning predicate + +Example: + +```ts +function isResolvedByFullTextProof(warning: string): boolean { + return ( + warning === 'OpenCode edit was captured without a proven full-text baseline; apply/reject is manual-only.' || + warning === 'OpenCode write overwrote an existing file before the bridge had a known baseline; reject is manual-only.' || + warning === 'OpenCode apply_patch was captured without full before/after text; review is manual-only.' || + warning === 'OpenCode toolpart content was unavailable or too large; review is manual-only.' || + warning.includes('full review depends on snapshot evidence') + ) +} + +function stripManualOnlyWarnings( + existing: string[] | undefined, + snapshotWarnings: string[] | undefined, +): string[] { + return [ + ...(existing ?? []).filter(warning => !isResolvedByFullTextProof(warning)), + ...(snapshotWarnings ?? []), + ].filter(Boolean) +} +``` + +If snapshot warnings contain unavailable content for this exact file, the change +should probably not have been upgraded. Add a test for that. + +### Phase 7 - Preserve Performance Limits And Add Budgets + +Do not increase these limits by default: + +- `maxFiles: 100` +- `maxBytesPerTextFile: 1024 * 1024` +- `maxTotalBytes: 4 * 1024 * 1024` +- `timeoutMs: 3000` + +Additional guard: + +```ts +const unresolved = params.changes.filter(needsSnapshotProof) +if (unresolved.length === 0) { + return result +} + +const touchedRelativePaths = [...new Set(unresolved.map(change => change.relativePath))] +``` + +Do not pass already exact changes into `touchedRelativePaths` unless needed for +chain verification. This keeps snapshot reads narrow. + +Add explicit performance budgets: + +- A no-op backfill with no unresolved OpenCode changes should not invoke the + snapshot reader. +- A strict-delivery task with one unresolved file should read one touched path. +- Snapshot proof attempt should record elapsed time in diagnostics when it + exceeds 500 ms. +- More than two snapshot timeouts in a real-data smoke run blocks rollout. +- The broad real-data smoke should not increase total runtime by more than 10% + compared with the baseline measured before the change. + +Implementation sketch: + +```ts +const startedAt = performance.now() +const snapshotResult = await readSnapshotEvidence() +const elapsedMs = performance.now() - startedAt +if (elapsedMs > 500) { + diagnostics.push(`snapshot-upgrade-slow: ${Math.round(elapsedMs)}ms`) +} +``` + +Use the local runtime timing primitive already used in the orchestrator if +`performance.now()` is not available in that module. + +Add a resource envelope for one backfill call: + +```ts +type SnapshotProofResourceEnvelope = { + maxSnapshotReadsPerBackfill: 10 + maxTouchedPathsPerRead: 100 + maxBytesPerTextFile: 1024 * 1024 + maxTotalBytesPerRead: 4 * 1024 * 1024 + maxElapsedMsPerRead: 3000 +} +``` + +Do not add hidden retries that can multiply these limits. One failed or timed +out snapshot read should produce diagnostics and preserve metadata-only changes. + +### Phase 8 - Idempotency And Existing Ledger Events + +The upgrade may change the materialized content for a source event that was +previously imported as metadata-only. That needs a clear policy. + +Preferred policy: + +1. Keep `sourceImportKey` stable. +2. Let the existing ledger importer treat the upgraded event as the same source + event, not a new file change. +3. If the importer is append-only and cannot update a previous event safely, + do not attempt to rewrite old ledger data in this feature. +4. For new tasks, the upgraded evidence should be imported on the first backfill. +5. For old tasks, a re-backfill can show better evidence only if the existing + ledger/import layer already supports replacing or superseding by source key. + +Add a test for repeated backfill. It should not duplicate files in the review +bundle. + +### Phase 9 - Desktop Contract Validation + +This phase should not add new UI behavior unless tests expose a bug. It validates +that the upgraded events are already consumed safely. + +Checklist: + +- Full-text upgraded OpenCode event renders through the same path as Codex full-text diffs. +- Metadata-only OpenCode event still renders the warning banner. +- Mixed full-text and metadata-only task keeps per-file rejectability. +- `Reject All` skips metadata-only files. +- Current disk preview remains read-only context. +- Task summary warnings remain visible if attribution or boundary warnings remain. + +If any item fails, fix the shared review safety helper rather than adding a +separate OpenCode-specific branch in the UI. + +## Observability And Metrics + +Add counters to diagnostics or existing debug output. They should be cheap and +safe to expose in test logs. + +Suggested counters: + +```ts +type SnapshotProofStats = { + attemptedChanges: number + upgradedChanges: number + skippedChanges: number + skippedByReason: Record + snapshotReadCount: number + snapshotReadTimeouts: number + snapshotReadElapsedMs: number + touchedPathCount: number + exactToolpartChainCount: number + metadataOnlyFallbackCount: number +} +``` + +Use these stats in smoke output: + +```text +OpenCode snapshot proof: +- attempted: 12 +- upgraded: 7 +- skipped: 5 +- skipped/no-window: 2 +- skipped/path-chain-ambiguous: 1 +- skipped/too-large: 2 +- snapshot reads: 3 +- snapshot read time: 184ms +``` + +Metrics must not include file content or secrets. Paths are acceptable only if +the existing diagnostics already expose paths in the same context. + +## Deterministic Output Comparison + +Use deterministic fingerprints to compare `off`, `shadow`, and apply modes. +This catches accidental behavior changes that are hard to see in UI screenshots. + +Suggested fingerprint input: + +```ts +type ReviewBundleFingerprintInput = Array<{ + taskId: string + relativePath: string + sourceImportKey: string + evidenceProof: string | undefined + operation: string + beforeSha256?: string + afterSha256?: string + warningCount: number + rejectable: boolean +}> +``` + +Rules: + +- `off` and `shadow` fingerprints must match except for diagnostics/stats. +- `single-change` may change OpenCode entries only. +- `full` may change OpenCode entries only. +- Non-OpenCode entries must have identical fingerprints in every mode. +- Fingerprints must not include raw file content. + +If a mode comparison fails, inspect the structured diff before looking at UI. + +## Cache And Re-Backfill Policy + +The safest initial policy is: + +- New backfills may import upgraded proof. +- Existing metadata-only events should not be rewritten unless the current + importer already has a proven source-key replacement/supersede path. +- The desktop cache should not be globally invalidated. +- A task-specific refresh may re-read after successful OpenCode import. +- If cache behavior is unclear, tests should bypass cache and the rollout should + leave old events unchanged. + +Pseudo-policy: + +```ts +type ExistingEventPolicy = 'new-imports-only' | 'supersede-by-source-key' + +function chooseExistingEventPolicy(audit: { + importerSupersedesBySourceKey: boolean + reviewBundleDedupesBySourceKey: boolean +}): ExistingEventPolicy { + return audit.importerSupersedesBySourceKey && audit.reviewBundleDedupesBySourceKey + ? 'supersede-by-source-key' + : 'new-imports-only' +} +``` + +Do not create a third policy that appends upgraded duplicates and relies on UI +filtering to hide the old event. + +## Rollback Runbook + +Rollback must be possible without data repair. + +Immediate rollback: + +```bash +OPENCODE_SNAPSHOT_PROOF_UPGRADE=off +``` + +Expected behavior after rollback: + +- New OpenCode backfills return to previous metadata-only/manual-only behavior + for cases without exact toolpart chains. +- Existing already-imported upgraded events remain valid historical full-text + events. Do not delete them as part of rollback. +- No new upgraded events should be imported while the flag is off. +- Desktop review should continue to render previously imported full-text events. + +If rollback is needed because upgraded duplicates were imported: + +1. Do not add renderer-side filtering as a permanent fix. +2. Identify whether duplicates share `sourceImportKey`. +3. Fix importer/source-key dedupe. +4. Add a regression test with the duplicated event fixture. +5. Only then consider a one-off ledger cleanup, and only with explicit user + approval. + +If rollback is needed because of performance: + +1. Keep diagnostics. +2. Disable proof upgrade. +3. Preserve exact `toolpart-chain` behavior. +4. Inspect snapshot read counters and touched path counts. +5. Re-enable only after reducing reads, not after raising limits. + +## Implementation Slices + +Prefer these slices even if the work lands in one PR. Each slice should compile +and have focused tests before the next slice starts. + +1. Diagnostics only: + - Add typed diagnostic codes. + - Add stats object. + - No behavior change. +2. Eligibility only: + - Add feature flag parser. + - Add `needsSnapshotProof` and `mayUseSnapshotProof`. + - Prove `off` mode has no behavior change. +3. Shadow proof: + - Compute proof decisions and stats. + - Return original changes to importer. + - Compare `shadow` and `off` outputs. +4. Single-change proof: + - Implement formal predicates. + - Implement create/modify/delete proof for one path/window/change. + - Keep multi-change groups skipped. +5. Import/idempotency validation: + - Verify source-key dedupe or choose `new-imports-only`. + - Add repeated-backfill tests. +6. Desktop validation: + - Verify shared rejectability consumes upgraded events safely. + - No OpenCode-specific renderer branch unless a shared helper bug is found. +7. Multi-change proof: + - Implement only after ordering contract tests pass. + - Keep behind `full`. +8. Default enablement: + - Enable `single-change` only after real-data smoke. + - Enable `full` only in a separate rollout decision. + +Stop points: + +- It is acceptable to stop after slice 3 and ship only `shadow`. +- It is acceptable to stop after slice 4 and ship only `single-change`. +- It is acceptable to stop after diagnostics if real data shows unsupported + snapshot shape. +- It is not acceptable to ship multi-change proof without real or synthetic + chain coverage. + +## Definition Of Done By Mode + +### `off` + +- No behavior change from current metadata-only/full-text decisions. +- Diagnostics may mention that the feature is disabled. +- Tests prove no upgraded event appears in this mode. + +### `shadow` + +Required before any apply mode can be default: + +- Proof attempts run for eligible OpenCode changes. +- Importer receives the original change list. +- Stats include would-upgrade and skipped counts. +- No review diff, rejectability, warning, or file count changes. +- Real-data smoke shows non-OpenCode teams unchanged. +- Performance budget passes while proof is computed but not applied. + +### `single-change` + +Required before this mode can be default: + +- Only one unresolved change for a path/window can upgrade. +- Multi-change path/window groups are skipped with diagnostics. +- `write` create/modify, `edit` modify, and delete cases have positive and + negative tests. +- Non-OpenCode teams are unchanged in real-data smoke. +- Metadata-only count for OpenCode tasks decreases or stays equal. +- No duplicate review rows after repeated backfill. + +### `full` + +Required before this mode can be default: + +- Every known unknown that blocks full mode is resolved. +- Same-path multi-change order is proven by tests. +- Chain upgrades are all-or-nothing for unresolved changes. +- Real-data smoke includes at least one actual multi-change chain or a synthetic + fixture with equivalent shape. +- `full` mode can be disabled without changing code. +- A separate rollout decision enables `full`; it must not become default as a + side effect of implementing single-change mode. + +## Edge Case Matrix + +### Attribution and Task Boundaries + +- No delivery context: + - Do not run strict snapshot upgrade. + - Keep existing backfill skipped behavior. +- Delivery context exists but does not include the requested task: + - Keep `no-attribution` behavior. Do not use compatible fallback for safe full-text. +- Compatible attribution mode: + - Do not upgrade to auto-safe full text. + - Reason: task ownership is not strict enough. +- Missing task start boundary: + - Snapshot proof may prove file content, but task boundary warning remains. +- Estimated end boundary: + - Snapshot proof may prove file content, but boundary warning remains. +- Same OpenCode session contains several tasks: + - Only strict delivery records for the requested task are eligible. +- Same member touches same file for two tasks: + - Do not merge changes across delivery windows. +- Multiple members share an OpenCode profile: + - Require the delivery record member/lane/session match. Do not trust profile alone. +- Runtime delivery ledger was reset after launch: + - No strict delivery context means no safe upgrade. Keep warnings. +- Delivery record has task refs but missing observed assistant message: + - Do not use message-local snapshot proof unless the toolpart can still be + tied to the delivered prompt through existing strict delivery matching. +- Delivery record has a pre-prompt cursor but no post-prompt cursor: + - Keep strict-delivery matching conservative. Do not widen to the whole session. +- Task display id matches but canonical task id differs: + - Use canonical task id for safe upgrades. + +### Snapshot Windows + +- No snapshot windows: + - Keep metadata-only warning. +- Toolpart outside window: + - Keep metadata-only warning. +- Toolpart matches multiple windows: + - Keep metadata-only warning. +- Window has before hash but no after hash: + - Keep metadata-only warning. +- Window has after hash but no before hash: + - Allow create only if file absence is explicitly proven. Otherwise keep warning. +- Snapshot diff contains the path but operation is unknown: + - Keep metadata-only warning. +- Snapshot diff includes more changed files than reconstructed toolparts: + - Upgrade only exact reconstructed paths. Add diagnostic for extra snapshot paths. +- Snapshot diff misses a reconstructed path: + - Keep that path metadata-only. +- OpenCode SQLite changed during read: + - Existing transaction snapshot is okay, but add diagnostic. +- OpenCode schema changed: + - Treat as unsupported history shape, no upgrade. +- Snapshot git store object is missing: + - Keep metadata-only warning and include the store diagnostic. +- Snapshot git store read times out: + - Keep metadata-only warning and include timeout diagnostic. +- Snapshot window hashes exist but git-store object is pruned: + - Keep metadata-only warning and include retention diagnostic. +- Snapshot window hashes exist but point to an object from a different project: + - Treat as workspace mismatch and skip. +- Snapshot window contains no reconstructed changes after path filtering: + - Do not read files for that window. +- Snapshot reader returns duplicate entries for one relative path: + - Treat as ambiguous and skip that path. +- Snapshot reader returns content for a path with different casing: + - Use existing normalized comparison key. If identity is ambiguous, skip. +- Snapshot window is valid but OpenCode part JSON was truncated by our reader + cap: + - Treat affected toolparts as metadata-only. Do not combine partial part data + with snapshot proof. +- Snapshot window contains changes from a tool type not modeled by this plan: + - Keep those changes metadata-only until the tool type has explicit tests. + +### File Content + +- Text over size limit: + - Keep warning with `too-large`. +- Binary or null-byte: + - Keep warning with `binary`. +- Empty file: + - Valid text content. Do not confuse empty string with unavailable. +- Missing file after delete: + - Valid delete if before content is known. +- Missing file before create: + - Valid create if after content is known. +- File exists before create operation: + - Operation mismatch, no upgrade. +- File absent after modify operation: + - Operation mismatch, no upgrade. +- Content normalizes differently by line endings: + - Do not normalize for proof. Exact byte-equivalent UTF-8 text comparison is required. +- Content has invalid UTF-8: + - Treat as binary/unavailable. +- Generated/minified text below limit: + - It can be upgraded if full text is available, but review UI may still choose to collapse display. +- File mode-only changes: + - Do not create a text diff upgrade unless text before/after also changed or mode changes are explicitly modeled. +- Very small binary file: + - Size does not make it text. Binary detection still wins. +- UTF-16 or other non-UTF-8 text: + - Treat as unavailable unless the existing snapshot reader explicitly decodes + and hashes the exact same text representation used by review events. +- Secrets in file content: + - Do not log content in diagnostics. Existing ledger storage rules apply to + before/after blobs. +- Git LFS pointer file: + - Treat the pointer text as the file content if that is what the snapshot + contains. Do not dereference LFS objects. +- Sparse checkout missing working-tree file: + - Irrelevant for proof. Snapshot evidence may still be valid, but execution + safety must handle current disk conflict separately. +- Submodule path: + - Do not read inside submodule git data unless existing snapshot reader + explicitly models submodules. Treat as metadata-only otherwise. +- Permission denied reading snapshot object: + - Keep metadata-only warning and include a permission diagnostic. + +### Edit Semantics + +- `oldString === newString`: + - Skip as no-op as current code does. +- `oldString` missing: + - Cannot prove edit from toolpart alone. +- `newString` missing: + - Cannot prove edit from toolpart alone. +- `oldString` appears twice: + - No upgrade unless snapshot chain proves exact transition through another trusted source. +- `newString` appears twice when reversing: + - No inverse upgrade. +- Replacement creates same final content through multiple possible paths: + - No upgrade. +- `replaceAll` or multi-replacement edit shape appears: + - Do not upgrade until that tool shape is explicitly parsed and tested. +- Edit tool reports success but snapshot before does not contain `oldString`: + - No upgrade. +- Edit tool reports success but snapshot after does not contain `newString`: + - No upgrade unless the replacement legitimately deletes the string and the exact transition verifies. +- Empty `oldString`: + - Do not upgrade. Empty search strings are ambiguous. +- Empty `newString`: + - Valid deletion only when `oldString` occurs exactly once and snapshot after + equals the deletion result. +- Overlapping replacements: + - Do not upgrade unless exact before-to-after application has one valid path. + +### Write Semantics + +- Write creates new file: + - Upgrade only if before absent and after content known. +- Write overwrites existing file: + - Upgrade only if snapshot before and snapshot after are known. +- Toolpart content differs from snapshot after: + - No upgrade. +- Toolpart content is truncated: + - Snapshot can still prove after only if snapshot after is available and operation is fully verified. + - Keep a diagnostic that toolpart content was truncated but snapshot proof was used. +- Existing file baseline unavailable: + - Keep current warning. +- Write after earlier edit in the same path/window: + - Treat as chain case. Do not single-change upgrade. +- Write followed by edit in the same path/window: + - Treat as chain case. Single-change upgrade is not enough. +- Write content is available but snapshot after is unavailable: + - Do not use toolpart content alone for existing-file overwrite baseline. +- Write content equals previous content: + - It may be a no-op. Do not create a misleading modify diff unless snapshot + shows a real text state transition or the review event model supports no-op + changes explicitly. +- Write creates parent directories: + - Only the file text is in scope. Directory creation is not a text proof. + +### Apply Patch Semantics + +- Patch text unavailable: + - Snapshot can prove final file-level before/after only if window has exact path anchor. +- Parsed update hunks apply exactly once: + - Allow inverse chain proof. +- Parsed hunks apply multiple places: + - No upgrade. +- Patch creates/deletes file: + - Verify operation with snapshot before/after states. +- Patch touches files not in metadata: + - Add diagnostic and do not upgrade missing paths unless snapshot path proof is exact. +- Patch contains rename: + - Do not upgrade as text modify unless rename support is explicitly modeled. +- Patch changes file mode only: + - Keep metadata-only unless mode changes are supported by the review event schema. +- Patch contains CRLF-sensitive context: + - Exact text verification is required. Do not line-ending-normalize. +- Patch partially applies in reverse: + - No upgrade. All hunks must verify. +- Patch has context-only hunks: + - Do not treat context as a change without before/after text proof. +- Patch deletes and recreates the same file in one patch: + - Treat as ambiguous unless the parser explicitly models it and tests cover it. + +### Paths and Workspaces + +- Absolute paths outside workspace: + - Reject upgrade. +- `..` path traversal: + - Reject upgrade. +- Windows path separators: + - Normalize, then validate. +- Symlink points outside workspace: + - Do not read current disk. Snapshot git store path normalization should be trusted only for repository paths. +- Session directory is subdirectory: + - Touched paths outside session directory get diagnostic. Do not let this alone prove or disprove content. +- Case-insensitive filesystem: + - Use existing OpenCode path comparison helpers. +- Unicode normalization differences in file names: + - Use existing normalized path keys. Do not add a second normalization scheme in this feature. +- Nested git repository inside workspace: + - Verify snapshot identity against the OpenCode project worktree, not just the process cwd. +- Worktree moved after task: + - Use recorded project identity. If workspace identity cannot be trusted, no upgrade. +- Workspace root is a symlink: + - Use existing workspace comparison helpers. Do not add ad-hoc `realpath` + behavior unless tests cover both symlinked and non-symlinked roots. +- File path contains newline or control characters: + - Do not include raw path in diagnostics without escaping. Upgrade only if + existing path normalization accepts it safely. +- Case-only rename: + - Treat as rename/path operation, not a text modify, unless the review event + schema explicitly models it. +- Path appears both as file and directory across before/after: + - Keep metadata-only unless snapshot reader explicitly models the transition. + +### Concurrency and Later Changes + +- Current disk changed after task: + - Irrelevant. Do not use current disk for proof. +- Another member changed same file after OpenCode task: + - Snapshot proof remains historical. Review conflict detection must happen elsewhere. +- Backfill runs twice: + - Source import keys must dedupe. +- Backfill interrupted: + - Existing ledger import must remain idempotent. +- OpenCode host is still writing SQLite: + - Rely on read-only transaction and existing fingerprint diagnostics. Do not retry aggressively. +- Two backfills run concurrently: + - Existing in-flight dedupe should prevent duplicate desktop calls. The importer must still dedupe by source key. +- User manually edits a file while review is open: + - Snapshot proof remains historical. Apply/reject conflict handling is outside this feature. +- Team is relaunched while backfill is running: + - Use run/session identity from the delivery context. Do not merge new runtime + sessions into the old task proof. +- Snapshot proof succeeds but ledger import fails: + - Retry should be idempotent by source key. Diagnostics should not mark the + task as safely upgraded until import succeeds. +- Snapshot store is pruned between capability check and file read: + - Treat the read failure as metadata-only fallback. Do not retry from current disk. +- OpenCode writes a new assistant message while backfill reads SQLite: + - Use the read-only transaction snapshot and existing fingerprint diagnostics. + Do not merge later rows into the current proof attempt. +- SQLite WAL is corrupt or cannot be read: + - Treat session history as unavailable/unsupported. Do not use partial rows for + safe proof. +- OpenCode JSON row is malformed: + - Skip that row and keep affected changes metadata-only. Do not infer from + surrounding rows. + +### UI And Review Semantics + +- Full-text upgrade enables normal diff rendering only if the imported event has + both safe baseline and safe target state. +- Metadata-only warnings should remain visible and should not be hidden by task + summary aggregation. +- `Reject All` must still skip non-rejectable files. +- A task-level warning may remain even when all file diffs are full-text. +- A file-level warning may remain even when another file in the same task is upgraded. +- Do not change viewed-count behavior in this feature. +- Do not hide task cards solely because all OpenCode warnings were resolved. +- Do not change accept/reject button labels or statuses in this feature. +- Do not mark a file viewed just because snapshot proof succeeded. +- A file becoming review-safe does not mean reject execution must succeed if the + user changed the worktree after the task. +- Conflict messaging for apply/reject should remain the existing shared + behavior, not a new OpenCode-only message path. +- If a task card warning disappears because all file-level OpenCode baseline + warnings were resolved, task-boundary and attribution warnings must still + remain visible. +- The UI should not describe a snapshot-upgraded file as "guaranteed safe". + It is "review-safe" or "full-text verified"; execution can still conflict. +- Do not add success toasts or celebratory messaging for proof upgrades. This is + infrastructure, not a user-facing achievement. + +### Security And Privacy + +- Do not log before/after content. +- Do not include long snippets in diagnostics. +- Do not include raw paths with control characters in diagnostics. +- Do not include delivery payload text in proof stats. +- Do not expand file size limits for convenience. +- Do not add a new IPC path that exposes arbitrary snapshot reads. +- If a file is upgraded, it is stored through the existing task-change ledger + content path. Do not add a second storage location. + +### Serialization And Backward Compatibility + +- Older desktop builds may see new diagnostics but should not require a new + event schema to render metadata-only fallback. +- Missing `snapshotSource` should not crash review rendering. +- Missing `snapshotId` should not crash review rendering. +- Unknown `evidenceProof` values should be treated as unsafe by review safety + helpers. +- JSON serialization must preserve empty string content. +- JSON serialization must distinguish absent file from empty file. +- Large content omitted by limits must serialize as unavailable state, not empty + content. + +## Test Plan + +### Risk To Test Traceability + +| Risk | Required test/smoke | +| --- | --- | +| Cross-task contamination | real-data smoke with at least two tasks in one OpenCode session | +| Cross-member contamination | fixture with shared profile but different member/lane | +| Wrong snapshot window | unit test with overlapping windows and outside-window toolpart | +| False baseline from current disk | unit test proving current disk is never consulted | +| Unsafe warning removal | unit test with unrelated `manual-only` warning preserved | +| Duplicate imported events | repeated-backfill bridge test | +| Performance regression | smoke budget with snapshot read counters | +| Unsupported OpenCode shape | snapshot provider unsupported-shape test | +| Mixed safe/unsafe task | desktop integration test for `Reject All` skipping metadata-only | +| Cache stale result | bridge or desktop worker test bypassing/invalidating cache deliberately | +| Capability false positive | fixture with snapshot enabled but missing store object | +| Shadow mode mutation | fingerprint comparison between `off` and `shadow` | +| Snapshot retention loss | fixture where window exists but object read fails | +| Execution conflict bypass | desktop/review test where current disk differs from expected after | +| Memory/storage blowup | fixture with many small files exceeding total byte budget | +| Malformed OpenCode rows | offline reader/reconstructor fixture with malformed part JSON | + +### Negative Control Fixtures + +Negative controls are cases that look close to valid proof but must not upgrade. + +Required negative controls: + +- Same file path, same member, but different task id. +- Same task id, same file path, but different member/lane. +- Same session and file path, but toolpart outside the snapshot window. +- Snapshot before/after text exists, but `oldString` occurs twice. +- Snapshot after equals toolpart content, but before is unavailable. +- Snapshot path matches, but operation is rename or mode-only. +- Current disk matches expected after, but snapshot before is missing. +- `shadow` computes an upgrade decision, but imported fingerprint matches `off`. +- Existing metadata-only event appears before upgraded event with same source key. +- Unknown `evidenceProof` appears in imported data. + +Each negative control should assert both behavior and diagnostic reason. A +negative control without a reason is hard to debug and easy to regress. + +### Golden Fixture Coverage Matrix + +Maintain a small set of golden fixtures that cover the supported state space. + +| Fixture | Mode | Expected | +| --- | --- | --- | +| write-create-text | `single-change` | upgraded create | +| write-modify-text | `single-change` | upgraded modify | +| edit-modify-once | `single-change` | upgraded modify | +| delete-text | `single-change` | upgraded delete | +| duplicate-old-string | `single-change` | metadata-only | +| missing-before | `single-change` | metadata-only | +| toolpart-outside-window | `single-change` | metadata-only | +| shadow-valid-edit | `shadow` | would-upgrade stats, original import | +| non-opencode-task | all modes | unchanged fingerprint | +| missing-snapshot-object | all apply modes | metadata-only | +| multi-change-chain | `single-change` | skipped | +| multi-change-chain | `full` | upgraded only if chain verifies | + +Golden fixtures should be tiny and deterministic. They should not depend on +wall-clock time, filesystem case behavior, or the user's current worktree. + +### Unit Tests + +Add or extend `OpenCodeChangeEvidenceEnricher.test.ts`. + +Tests: + +1. Upgrades metadata-only edit from exact snapshot before/after. +2. Does not upgrade edit when `oldString` appears twice. +3. Does not upgrade edit when snapshot after does not equal applied result. +4. Upgrades write create with before absent and after text. +5. Upgrades write modify when toolpart content equals snapshot after. +6. Does not upgrade write modify when toolpart content differs from snapshot after. +7. Upgrades delete with before text and after absent. +8. Does not remove attribution warnings after content proof. +9. Keeps manual-only warning when anchor has unavailable before/after content. +10. Multi-edit same-path chain upgrades only when the whole chain verifies. +11. Multi-edit same-path chain keeps all metadata-only fallbacks when one step is ambiguous. +12. Snapshot provider unavailable keeps current behavior. +13. Does not remove unrelated `manual-only` warning text. +14. Keeps task boundary warnings after successful content proof. +15. Does not upgrade compatible attribution mode. +16. Does not upgrade when feature flag is `off`. +17. Single-change mode skips multi-change chain upgrade. +18. Empty file create and empty file modify are handled as valid text. +19. CRLF/LF mismatch fails proof instead of normalizing. +20. Duplicate source import keys block chain upgrade. +21. Empty `newString` deletion upgrades only with exact single occurrence. +22. Empty `oldString` never upgrades. +23. State hashes must match emitted full text. +24. Snapshot anchor duplicate path entry skips upgrade. +25. `write` no-op does not create a misleading diff. +26. Skipped proof preserves the original change object fields. +27. Successful proof mutates only allowed fields. +28. Snapshot proof decision returns typed skipped reason, not `null`. +29. Unsupported snapshot shape never upgrades. +30. Existing-event policy defaults to `new-imports-only` when dedupe is unknown. +31. State machine cannot jump from candidate to upgraded without transition verification. +32. Unstable part order blocks multi-change upgrade. +33. Unknown `evidenceProof` is unsafe in review safety helper. +34. Empty string survives materialization and serialization. +35. Absent file is not serialized as empty file. +36. `shadow` mode computes proof stats but returns original changes. +37. `mayApplySnapshotProof` blocks multi-change groups in `single-change`. +38. Exhaustive switches fail compilation when a new mode/proof decision is not handled. +39. Capability success is required before snapshot proof attempt. +40. Missing snapshot git-store object keeps metadata-only fallback. +41. `off` and `shadow` review bundle fingerprints match. +42. Non-OpenCode fingerprints are identical across all modes. +43. Review-safe upgraded change still fails reject execution when current disk mismatches expected after. +44. Total byte budget skips excess files as metadata-only. +45. Malformed OpenCode part JSON cannot produce upgraded proof. +46. LFS pointer text is not dereferenced. +47. Submodule paths stay metadata-only unless explicitly modeled. +48. Runtime postcondition failure preserves original metadata-only change. +49. Every golden fixture has a paired negative control. +50. Minimum safe scope excludes unsupported operation shapes. + +Example fixture shape: + +```ts +const change: ReconstructedOpenCodeToolChange = { + taskId: 'task-1', + taskRef: 'task-1', + taskRefKind: 'canonical', + teamName: 'team', + memberName: 'alice', + sessionId: 'session', + assistantMessageId: 'message-1', + toolUseId: 'tool-1', + sourcePartId: 'part-1', + sourceMessageId: 'message-1', + sourceTool: 'edit', + sourceImportKey: 'session:part-1:src/app.ts', + filePath: '/workspace/src/app.ts', + relativePath: 'src/app.ts', + beforeContent: null, + afterContent: null, + operation: 'modify', + confidence: 'medium', + attributionMethod: 'delivery-ledger-taskrefs', + oldString: 'const value = 1', + newString: 'const value = 2', + beforeState: { exists: true, unavailableReason: 'opencode-edit-baseline-not-captured' }, + afterState: { exists: true, unavailableReason: 'opencode-edit-final-content-unavailable' }, + evidenceProof: 'metadata-only-fallback', + warnings: ['OpenCode edit was captured without a proven full-text baseline; apply/reject is manual-only.'], + timestamp: new Date(0).toISOString(), +} +``` + +### Synthetic Fixture Schema + +Use a compact fixture builder so edge cases do not depend entirely on live +OpenCode data. + +```ts +type SnapshotProofFixture = { + name: string + mode: SnapshotProofUpgradeMode + attributionMode: OpenCodeLedgerAttributionMode + delivery: { + teamName: string + taskId: string + memberName: string + laneId?: string + sessionId: string + assistantMessageId: string + } + windows: Array<{ + messageId: string + windowId: string + fromSnapshot: string + toSnapshot: string + startPartOrder: number + finishPartOrder: number + }> + parts: Array<{ + partId: string + messageId: string + order: number + tool: 'write' | 'edit' | 'apply_patch' + filePath: string + oldString?: string + newString?: string + content?: string + }> + snapshotFiles: Array<{ + relativePath: string + beforeContent?: string + afterContent?: string + beforeExists: boolean + afterExists: boolean + }> + expected: { + upgraded: number + metadataOnly: number + diagnostics: string[] + } +} +``` + +Fixture rules: + +- Every positive fixture needs a paired negative fixture that differs by one + proof condition. +- Fixtures should prefer tiny strings so failures are easy to inspect. +- Fixtures must include at least one empty string case and one absent-file case. +- Fixtures must include one path with unsafe characters for diagnostics escaping. +- Fixtures must not include secrets or large blobs. + +### Snapshot Provider Tests + +Extend `OpenCodeSnapshotEvidenceProvider.test.ts`. + +Tests: + +1. Groups only unresolved proof-needed changes into touched paths. +2. Emits diagnostic for missing window. +3. Emits diagnostic for ambiguous window. +4. Preserves existing limits. +5. Does not read snapshot for unrelated exact changes. +6. Does not match windows across assistant messages. +7. Emits diagnostic for extra snapshot paths not in reconstructed toolparts. +8. Emits timeout diagnostic while preserving metadata-only fallback. +9. Does not read snapshot windows with no unresolved touched paths. +10. Escapes unsafe path text in diagnostics. + +### Ledger Bridge Tests + +Extend `OpenCodeLedgerBridgeService` tests or add a focused fixture test. + +Tests: + +1. Backfill imports upgraded full-text event for strict delivery OpenCode edit. +2. Backfill keeps metadata-only event for compatible attribution. +3. Backfill keeps metadata-only event with no delivery context. +4. Imported event has stable source import key and dedupes on rerun. +5. `snapshotShapeFingerprint` is present when snapshot proof was used. +6. Repeated backfill does not duplicate file entries. +7. Old metadata-only imported event is not rewritten unless importer already supports superseding by source key. +8. Snapshot proof is not attempted for Codex or Anthropic members. +9. Snapshot proof is not attempted for OpenCode exact `toolpart-chain` changes. +10. Backfill cache does not return stale metadata-only data after an upgraded import in the same test. +11. Import failure leaves no partial safe-review state. + +### Desktop Integration Tests + +Only if needed. The desktop review UI already handles full text and metadata-only. + +Smoke check: + +1. Full-text OpenCode upgraded event renders a real diff. +2. Metadata-only event still renders manual-only warning. +3. Reject is enabled only for full-text safe baseline. +4. Warnings remain visible for task boundary uncertainty. +5. `Reject All` skips a mixed task where one OpenCode file upgraded and another stayed metadata-only. +6. Current disk preview remains read-only and does not become a reject baseline. +7. Viewed count is unchanged by proof upgrade. +8. Task-level boundary warning remains visible after all file diffs upgrade. +9. Reject execution still blocks when current disk no longer matches the expected + after state. +10. Bulk `Reject All` rejects only files that pass both review-safety and + execution-safety checks. + +### Property-Like Tests + +Add small table-driven tests for transition verification: + +```ts +const editCases = [ + { name: 'single replacement', before: 'a = 1', oldString: '1', newString: '2', after: 'a = 2', ok: true }, + { name: 'duplicate old', before: 'a 1 b 1', oldString: '1', newString: '2', after: 'a 2 b 1', ok: false }, + { name: 'empty old', before: 'abc', oldString: '', newString: 'x', after: 'xabc', ok: false }, + { name: 'delete exactly once', before: 'abc', oldString: 'b', newString: '', after: 'ac', ok: true }, +] +``` + +The point is not random fuzzing. The point is to make ambiguous replacement +rules explicit and hard to regress. + +### Real Data Smoke + +Before implementation, capture a baseline: + +```bash +time pnpm test --run test/main/services/team/TaskChangeComputer.test.ts +time pnpm test --run test/main/services/team/ChangeExtractorService.test.ts +``` + +After implementation, run the same commands: + +```bash +pnpm test --run test/main/services/team/TaskChangeComputer.test.ts +pnpm test --run test/main/services/team/ChangeExtractorService.test.ts +``` + +Then run the existing real-data smoke scripts used for task changes. Required +checks: + +- `errors: 0` +- no increase in item errors +- no cross-task file leakage +- no increase in metadata-only count for OpenCode tasks +- no change for Codex-only teams +- no change for Anthropic-only teams +- broad smoke runtime increase <= 10% +- snapshot timeout count <= 2 +- upgraded OpenCode full-text count is explainable by diagnostics +- no decrease in task-boundary warnings unless task-boundary code changed separately +- `off` and `shadow` fingerprints match except diagnostics/stats +- non-OpenCode fingerprints match in all modes + +Manual target cases: + +- `relay-works-3/#1f735bea` +- `relay-works-3/#bf01e5c3` +- `relay-works-3/#43e6b9b0` should remain Codex-related, not OpenCode-upgraded +- `signal-ops-22` should remain unaffected because it has no OpenCode members +- any OpenCode team with real `snapshotShapeFingerprint` present in diagnostics +- one team with missing/reset delivery ledger, if available + +Add at least one synthetic OpenCode snapshot fixture if real data lacks a clean +single-change full-text snapshot case. Real data validates integration, but a +synthetic fixture is better for precise edge cases. + +Real-data smoke should compare before/after summaries: + +```text +Before: +- OpenCode metadata-only file changes: N +- OpenCode full-text file changes: M +- non-OpenCode full-text file changes: X +- task-boundary warnings: B + +After: +- OpenCode metadata-only file changes: <= N +- OpenCode full-text file changes: >= M +- non-OpenCode full-text file changes: X +- task-boundary warnings: B +``` + +Any non-OpenCode count change is a blocker. + +### Failure Injection Tests + +Add targeted failure injection where practical: + +- Snapshot provider throws. +- Snapshot provider times out. +- Snapshot provider returns duplicate path entries. +- Ledger importer rejects the batch. +- Backfill runs twice with the same source import key. +- Feature flag changes from `single-change` to `off`. +- Snapshot proof succeeds for one file and fails for another file in the same task. + +Expected result for every failure injection: original metadata-only safety is +preserved, no duplicate review rows, diagnostics explain the skip/failure. + +### Serialization Tests + +Add tests around the task-change event materialization boundary: + +- `beforeContent: ''` remains empty string. +- `afterContent: ''` remains empty string. +- `beforeContent: null` remains unavailable/absent according to state. +- Unknown `evidenceProof` does not make a file rejectable. +- Snapshot fields survive import/export if present. +- Snapshot fields may be absent without renderer crashes. + +### Manual QA Runbook + +Manual QA is not a substitute for tests, but it helps catch integration mistakes. + +Prepare: + +1. Pick one OpenCode team with snapshot evidence. +2. Pick one Codex-only or Anthropic-only team. +3. Record before counts for: + - OpenCode metadata-only files. + - OpenCode full-text files. + - non-OpenCode full-text files. + - task-boundary warnings. + - snapshot proof diagnostics. + +Run with `off`: + +```bash +OPENCODE_SNAPSHOT_PROOF_UPGRADE=off pnpm test --run test/main/services/team/TaskChangeComputer.test.ts +``` + +Expected: + +- No new upgraded OpenCode snapshot events. +- Existing exact toolpart-chain behavior unchanged. + +Run with `shadow`: + +```bash +OPENCODE_SNAPSHOT_PROOF_UPGRADE=shadow pnpm test --run test/main/services/team/TaskChangeComputer.test.ts +``` + +Expected: + +- Snapshot proof stats are emitted. +- Would-upgrade counts are visible. +- Imported/reviewed changes are identical to `off`. +- Any difference from `off` outside diagnostics is a blocker. + +Run with `single-change`: + +```bash +OPENCODE_SNAPSHOT_PROOF_UPGRADE=single-change pnpm test --run test/main/services/team/TaskChangeComputer.test.ts +``` + +Expected: + +- OpenCode full-text count may increase. +- OpenCode metadata-only count may decrease or stay equal. +- non-OpenCode counts are unchanged. +- Multi-change groups are skipped with diagnostics. + +Run with `full` only after tests pass: + +```bash +OPENCODE_SNAPSHOT_PROOF_UPGRADE=full pnpm test --run test/main/services/team/TaskChangeComputer.test.ts +``` + +Expected: + +- Same guarantees as `single-change`. +- Multi-change upgrades appear only when diagnostics can explain the full chain. + +UI spot check: + +- Open a mixed task with one upgraded file and one metadata-only file. +- Verify the upgraded file shows a diff. +- Verify the metadata-only file still shows a warning. +- Verify `Reject All` skips metadata-only files. +- Verify current disk preview is not treated as baseline. +- Verify task boundary warnings remain if present. + +Any mismatch is a blocker. + +## Acceptance Criteria + +The implementation is acceptable only if all are true: + +- OpenCode-only behavior changed. +- Strict delivery remains required for snapshot full-text upgrades. +- Exact existing `toolpart-chain` behavior is unchanged. +- Metadata-only fallback still works. +- No current disk content is used as historical proof. +- No broad OpenCode session scan is introduced. +- Snapshot read limits are unchanged or narrower. +- Ambiguous chains keep warnings. +- Large and binary files keep warnings. +- Tests cover same-path multi-change chains. +- Real-data smoke shows no cross-task leakage. +- Feature flag can disable the upgrade. +- Repeated backfill does not duplicate review files. +- Warning removal is limited to known resolved warning predicates. +- Performance budgets pass. +- The implementation has an explicit fallback for unsupported OpenCode snapshot shapes. +- The implementation includes Phase 0 contract audit notes in the PR/commit + description or test output. +- No warning is removed unless a unit test names that exact warning or predicate. +- No current-disk preview path is involved in a proof decision. +- No behavior change occurs when `OPENCODE_SNAPSHOT_PROOF_UPGRADE=off`. +- Smoke output includes attempted/upgraded/skipped counts. +- Full mode is not enabled while any known unknown remains unresolved. +- Existing metadata-only events are not rewritten unless source-key supersede is + proven by tests. +- Cache behavior is documented in the Phase 0 audit. +- Composite proof identity is enforced before snapshot text is trusted. +- Toolpart ordering is explicitly verified before multi-change upgrades. +- `single-change` and `full` have separate definitions of done. +- Serialization preserves empty string versus absent file. +- `shadow` mode proves expected upgrades without changing imported review events. +- Exhaustive handling covers every proof decision and feature flag mode. +- Capability gates are checked per session, not inferred from config alone. +- Missing/pruned snapshot store objects preserve metadata-only fallback. +- Deterministic fingerprints prove non-OpenCode behavior is unchanged. +- Apply/reject execution safety still checks current disk state after review + proof succeeds. +- Storage and memory budgets are enforced without duplicate blob storage. +- Malformed/truncated OpenCode rows cannot produce upgraded proof. +- First apply rollout stays within the minimum safe scope. +- Negative controls prove close-but-invalid cases remain metadata-only. +- Runtime postcondition failures preserve original changes. + +## Verification Command Matrix + +Use the narrowest useful commands first, then broader smoke. + +| Layer | Command or check | Required result | +| --- | --- | --- | +| Typecheck | `pnpm typecheck` | passes | +| Enricher unit | targeted `OpenCodeChangeEvidenceEnricher` tests | passes | +| Snapshot provider | targeted `OpenCodeSnapshotEvidenceProvider` tests | passes | +| Ledger bridge | targeted `OpenCodeLedgerBridgeService` tests | passes | +| Desktop review safety | targeted review/rejectability tests | passes | +| Off mode | task-change tests with `OPENCODE_SNAPSHOT_PROOF_UPGRADE=off` | old behavior | +| Shadow mode | task-change tests with `OPENCODE_SNAPSHOT_PROOF_UPGRADE=shadow` | stats only | +| Single-change mode | task-change tests with `OPENCODE_SNAPSHOT_PROOF_UPGRADE=single-change` | only one-change upgrades | +| Full mode | task-change tests with `OPENCODE_SNAPSHOT_PROOF_UPGRADE=full` | only after chain tests | +| Real data | existing task-change smoke on OpenCode and non-OpenCode teams | no leakage | + +Do not use `full` smoke as a substitute for single-change smoke. They prove +different safety boundaries. + +## Code Review Checklist + +Use this checklist before merging the implementation: + +- Every upgraded change has a non-`metadata-only-fallback` proof. +- Every upgraded modify has both `beforeContent` and `afterContent`. +- Every upgraded create has `beforeState.exists === false` and `afterContent`. +- Every upgraded delete has `beforeContent` and `afterState.exists === false`. +- State hashes match emitted content. +- No branch reads current disk as proof. +- No branch catches an error and upgrades anyway. +- Every skipped branch preserves the original change. +- Warning stripping uses a central predicate. +- Multi-change mode can be disabled independently. +- Snapshot reader limits are unchanged or narrower. +- Tests include at least one negative case for every positive upgrade case. +- Real-data smoke includes at least one OpenCode team and one non-OpenCode team. +- No new IPC or filesystem read path bypasses existing workspace trust checks. +- No content appears in diagnostics, metrics, or thrown error messages. +- `off` mode is covered by a test and is easy to use during rollback. +- The proof logic is structured so forbidden state transitions are not possible + without an obvious code review smell. +- `shadow` mode has been run on real data before any apply mode is enabled. +- Any new union member requires an exhaustive switch update, not a permissive + default branch. +- Review-safe and execution-safe are checked separately. +- Large-file and total-byte budget tests prove metadata-only fallback. +- Minimum safe scope is visible in code structure, not only in tests. +- Negative controls exist for task, member, window, baseline, and operation + mismatch. + +## Implementation Anti-Patterns + +Do not implement the feature using these patterns: + +- A broad `try/catch` that returns an upgraded change on partial data. +- Mutating the original change object in place before proof has succeeded. +- Removing warnings before the final proof decision. +- Reading current disk to fill `beforeContent`. +- Comparing normalized line endings for proof. +- Treating a matching hash as content. +- Creating OpenCode-specific rejectability logic in the renderer. +- Appending upgraded duplicate events and expecting UI sorting to hide stale ones. +- Increasing snapshot limits to make a test pass. +- Falling back from strict delivery to compatible attribution for safety. +- Adding `full` mode as the default in the same change that introduces it. +- Treating empty string as missing content. +- Treating missing content as empty string. +- Sorting same-path chains by only one field. +- Retrying snapshot reads in a loop without a budget. +- Treating review-safe as automatically execution-safe. +- Adding a second blob store or cache for before/after content. +- Dereferencing Git LFS or submodule content outside the existing snapshot reader. +- Using malformed partial OpenCode JSON rows as proof context. +- Expanding the first apply mode to unsupported operations because the snapshot + text happens to be available. +- Hiding uncertainty by changing user-facing wording from warning to success. + +## Rollout Strategy + +1. Land diagnostics and helper functions with no behavior change if practical. +2. Add the feature flag with default `off` in tests where needed. +3. Add snapshot-first upgrade for single-change same-path cases. +4. Run targeted tests and real-data smoke. +5. Enable `single-change` mode for local smoke. +6. Add multi-change chain upgrade only after tests are solid. +7. Move to `full` mode only if multi-change smoke is clean. +8. Inspect warnings before and after for OpenCode tasks. + +If multi-change support looks risky during implementation, stop after +single-change mode. Single-change upgrade is already useful and lower risk. + +Recommended shipping sequence: + +```text +PR 1: diagnostics + eligibility helpers + no behavior change +PR 2: single-change snapshot proof upgrade behind flag +PR 3: enable single-change by default for OpenCode strict delivery +PR 4: multi-change chain upgrade behind flag +PR 5: enable full mode only after real-data smoke +``` + +If this stays as one PR, keep the same commit structure locally and verify each +step before moving to the next one. + +## Abort Conditions + +Do not continue implementation if any of these happens: + +- Snapshot windows cannot be reliably matched to toolparts. +- Existing OpenCode snapshot shape differs from tests in real data. +- Real-data smoke shows any new cross-task file leakage. +- Performance smoke shows repeated timeouts. +- A change would require using current disk as proof. +- A change would require broad compatible attribution scanning. +- Warning stripping needs broad substring matching to pass tests. +- Multi-change support requires accepting ambiguous edit/apply-patch replacements. +- Source import key dedupe behavior is unclear. +- The only available validation is manual UI inspection. +- A test has to assert against current wall-clock timing without a stable budget. +- Formal proof predicates require exceptions to support the first implementation. +- Postconditions fail for any positive fixture. +- `single-change` mode needs multi-change assumptions to pass. +- `full` mode needs renderer-specific special cases to appear safe. +- Rollback with `OPENCODE_SNAPSHOT_PROOF_UPGRADE=off` does not restore old + behavior for new backfills. +- Empty content and missing content cannot be distinguished at serialization. + +## Open Questions Template For Implementation PR + +Every implementation PR should answer these in its description: + +```text +OpenCode snapshot proof PR checklist: +- Mode implemented: diagnostics | single-change | full +- Default mode: +- Phase 0 contract audit completed: yes | no +- Source import key duplicate policy: +- Review bundle dedupe key: +- Rejectability helper: +- Existing event policy: new-imports-only | supersede-by-source-key +- Snapshot shape fingerprint observed: +- Real-data teams tested: +- Non-OpenCode teams unchanged: yes | no +- Snapshot proof stats: +- Rollback tested with OPENCODE_SNAPSHOT_PROOF_UPGRADE=off: yes | no +- Known unknowns remaining: +``` + +If the PR cannot answer one of these, it should not enable new behavior by +default. + +## Example Final Change Shape + +Before upgrade, metadata-only edit: + +```json +{ + "sourceTool": "edit", + "before": { + "exists": true, + "unavailableReason": "opencode-edit-baseline-not-captured" + }, + "after": { + "exists": true, + "unavailableReason": "opencode-edit-final-content-unavailable" + }, + "evidenceProof": "metadata-only-fallback", + "warnings": [ + "OpenCode edit was captured without a proven full-text baseline; apply/reject is manual-only." + ] +} +``` + +After verified snapshot upgrade: + +```json +{ + "sourceTool": "edit", + "before": { + "exists": true, + "sha256": "before-hash", + "sizeBytes": 128 + }, + "after": { + "exists": true, + "sha256": "after-hash", + "sizeBytes": 128 + }, + "evidenceProof": "opencode-snapshot", + "snapshotSource": "opencode", + "warnings": [] +} +``` + +If any proof check fails, the event must stay in the first shape. + +## Notes for Future Maintainers + +The important invariant is not "fewer warnings". The invariant is "warnings are +removed only when the system has stronger evidence than before". + +Warnings are correct when historical full text is not proven. A warning is a +better outcome than an unsafe reject button. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 9b69acad..1c6486c5 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -44,30 +44,42 @@ function nativeModuleStub(): Plugin { } } -// Sentry source map upload — only active in CI when SENTRY_AUTH_TOKEN is set. -const sentryPlugins = process.env.SENTRY_AUTH_TOKEN - ? [ - sentryVitePlugin({ - org: process.env.SENTRY_ORG ?? 'quant-jump-pro', - project: process.env.SENTRY_PROJECT ?? 'electron', - authToken: process.env.SENTRY_AUTH_TOKEN, - release: { name: `agent-teams-ai@${pkg.version}` }, - sourcemaps: { - filesToDeleteAfterUpload: ['./out/renderer/**/*.map', './dist-electron/**/*.map'], - }, - }), - ] - : [] +const sentrySourceMapTargets = { + main: { + assets: ['./dist-electron/main/**/*.{js,cjs,mjs,map}'], + filesToDeleteAfterUpload: ['./dist-electron/main/**/*.map'], + }, + renderer: { + assets: ['./out/renderer/**/*.{js,cjs,mjs,map}'], + filesToDeleteAfterUpload: ['./out/renderer/**/*.map'], + }, +} as const + +// Sentry source map upload - only active in CI when SENTRY_AUTH_TOKEN is set. +function createSentryPlugins(target: keyof typeof sentrySourceMapTargets): Plugin[] { + if (!process.env.SENTRY_AUTH_TOKEN) return [] + + return [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG ?? 'quant-jump-pro', + project: process.env.SENTRY_PROJECT ?? 'electron', + authToken: process.env.SENTRY_AUTH_TOKEN, + telemetry: false, + release: { name: `agent-teams-ai@${pkg.version}` }, + sourcemaps: sentrySourceMapTargets[target], + }) as Plugin, + ] +} export default defineConfig({ main: { plugins: [ nativeModuleStub(), - ...sentryPlugins, + ...createSentryPlugins('main'), ], define: { __APP_VERSION__: JSON.stringify(pkg.version), - // Inject DSN at compile time — process.env.SENTRY_DSN is NOT available + // Inject DSN at compile time - process.env.SENTRY_DSN is NOT available // at runtime in packaged Electron apps (only during CI build). 'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN ?? ''), }, @@ -148,10 +160,14 @@ export default defineConfig({ '@renderer': resolve(__dirname, 'src/renderer'), '@shared': resolve(__dirname, 'src/shared'), '@main': resolve(__dirname, 'src/main'), + '@radix-ui/react-compose-refs': resolve( + __dirname, + 'src/renderer/vendor/radixComposeRefs.ts' + ), '@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts') } }, - plugins: [react(), ...sentryPlugins], + plugins: [react(), ...createSentryPlugins('renderer')], build: { sourcemap: 'hidden', rollupOptions: { diff --git a/landing/.gitignore b/landing/.gitignore index c7d2a8bc..392f1564 100644 --- a/landing/.gitignore +++ b/landing/.gitignore @@ -3,6 +3,8 @@ node_modules .output .dist .env +--host/ +product-docs/.vitepress/dist/ # Large video files public/video/*.mp4 diff --git a/landing/assets/images/footer/robot-lead-lounge-v1.webp b/landing/assets/images/footer/robot-lead-lounge-v1.webp new file mode 100644 index 00000000..67876934 Binary files /dev/null and b/landing/assets/images/footer/robot-lead-lounge-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-amber-v1.webp b/landing/assets/images/hero/robots/robot-avatar-amber-v1.webp new file mode 100644 index 00000000..d872a124 Binary files /dev/null and b/landing/assets/images/hero/robots/robot-avatar-amber-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-cyan-cat-v1.webp b/landing/assets/images/hero/robots/robot-avatar-cyan-cat-v1.webp new file mode 100644 index 00000000..27b392ae Binary files /dev/null and b/landing/assets/images/hero/robots/robot-avatar-cyan-cat-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-cyan-v1.webp b/landing/assets/images/hero/robots/robot-avatar-cyan-v1.webp new file mode 100644 index 00000000..ce12d9ba Binary files /dev/null and b/landing/assets/images/hero/robots/robot-avatar-cyan-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-magenta-v1.webp b/landing/assets/images/hero/robots/robot-avatar-magenta-v1.webp new file mode 100644 index 00000000..74136f6d Binary files /dev/null and b/landing/assets/images/hero/robots/robot-avatar-magenta-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-red-flame-v1.webp b/landing/assets/images/hero/robots/robot-avatar-red-flame-v1.webp new file mode 100644 index 00000000..a32935a7 Binary files /dev/null and b/landing/assets/images/hero/robots/robot-avatar-red-flame-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-reviewer-teal-v1.webp b/landing/assets/images/hero/robots/robot-avatar-reviewer-teal-v1.webp new file mode 100644 index 00000000..d081566c Binary files /dev/null and b/landing/assets/images/hero/robots/robot-avatar-reviewer-teal-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp b/landing/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp new file mode 100644 index 00000000..90f27c1e Binary files /dev/null and b/landing/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-avatar-yellow-star-v1.webp b/landing/assets/images/hero/robots/robot-avatar-yellow-star-v1.webp new file mode 100644 index 00000000..f49a5450 Binary files /dev/null and b/landing/assets/images/hero/robots/robot-avatar-yellow-star-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-red-purple-handshake-v1.webp b/landing/assets/images/hero/robots/robot-red-purple-handshake-v1.webp new file mode 100644 index 00000000..98a0c794 Binary files /dev/null and b/landing/assets/images/hero/robots/robot-red-purple-handshake-v1.webp differ diff --git a/landing/assets/images/hero/robots/robot-seated-magenta-v1.png b/landing/assets/images/hero/robots/robot-seated-magenta-v1.png new file mode 100644 index 00000000..eecfb33c Binary files /dev/null and b/landing/assets/images/hero/robots/robot-seated-magenta-v1.png differ diff --git a/landing/assets/images/hero/robots/robot-seated-magenta-v1.webp b/landing/assets/images/hero/robots/robot-seated-magenta-v1.webp new file mode 100644 index 00000000..512d48eb Binary files /dev/null and b/landing/assets/images/hero/robots/robot-seated-magenta-v1.webp differ diff --git a/landing/assets/images/references/cyber-hud-header-reference-2026-05-16.png b/landing/assets/images/references/cyber-hud-header-reference-2026-05-16.png new file mode 100644 index 00000000..092bb156 Binary files /dev/null and b/landing/assets/images/references/cyber-hud-header-reference-2026-05-16.png differ diff --git a/landing/assets/styles/cyberpunk-hero.scss b/landing/assets/styles/cyberpunk-hero.scss index 2018b34b..cc3ebccd 100644 --- a/landing/assets/styles/cyberpunk-hero.scss +++ b/landing/assets/styles/cyberpunk-hero.scss @@ -14,17 +14,218 @@ --cyber-muted: #9ba8c7; --cyber-border-cyan: rgba(0, 234, 255, 0.42); --cyber-border-magenta: rgba(255, 43, 255, 0.42); + --cyber-panel-bg: + linear-gradient(135deg, rgba(5, 14, 31, 0.9), rgba(3, 10, 22, 0.64)); + --cyber-panel-shadow: + 0 0 0 1px rgba(47, 125, 255, 0.12) inset, + 0 0 24px rgba(0, 234, 255, 0.12); + --cyber-hero-bg: + radial-gradient(circle at 76% 30%, rgba(0, 234, 255, 0.14), transparent 30%), + radial-gradient(circle at 86% 70%, rgba(255, 43, 255, 0.14), transparent 34%), + linear-gradient(180deg, var(--cyber-bg-0), var(--cyber-bg-1) 58%, var(--cyber-bg-0)); + --cyber-monterey-bg: + radial-gradient(circle at 76% 22%, rgba(138, 47, 255, 0.76), transparent 30%), + radial-gradient(circle at 18% 76%, rgba(37, 8, 128, 0.72), transparent 36%), + linear-gradient(180deg, #180061 0%, #3200a2 46%, #130042 100%); + --cyber-monterey-before-bg: + radial-gradient(circle at 18% 34%, rgba(2, 5, 13, 0.62), rgba(2, 5, 13, 0.14) 34%, transparent 62%), + linear-gradient(90deg, rgba(2, 5, 13, 0.48) 0%, rgba(2, 5, 13, 0.17) 42%, rgba(2, 5, 13, 0.04) 64%, rgba(2, 5, 13, 0.3) 100%); + --cyber-monterey-after-bg: + linear-gradient(180deg, rgba(2, 5, 13, 0.92) 0%, rgba(2, 5, 13, 0.62) 15%, rgba(2, 5, 13, 0.08) 44%, rgba(2, 5, 13, 0.68) 100%), + radial-gradient(circle at 64% 42%, transparent 0 26%, rgba(2, 5, 13, 0.24) 70%, rgba(2, 5, 13, 0.54) 100%); + --cyber-monterey-canvas-opacity: 1; + --cyber-monterey-canvas-filter: blur(4px) saturate(1.22) brightness(1.08) contrast(1.08); + --cyber-monterey-canvas-blend: normal; + --cyber-background-bg: + radial-gradient(circle at 72% 28%, rgba(0, 234, 255, 0.1), transparent 30%), + radial-gradient(circle at 88% 62%, rgba(255, 43, 255, 0.1), transparent 32%), + linear-gradient(90deg, transparent 0 64px, rgba(0, 234, 255, 0.045) 65px 66px, transparent 67px 160px), + linear-gradient(180deg, rgba(2, 5, 13, 0.66) 0%, rgba(2, 5, 13, 0.2) 38%, rgba(2, 5, 13, 0.78) 100%); + --cyber-background-opacity: 0.58; + --cyber-background-before-bg: + linear-gradient(90deg, transparent 0 8%, rgba(0, 234, 255, 0.14) 8.1% 8.22%, transparent 8.34% 18%, rgba(255, 43, 255, 0.12) 18.1% 18.22%, transparent 18.34% 31%, rgba(0, 234, 255, 0.11) 31.1% 31.24%, transparent 31.36% 44%, rgba(47, 125, 255, 0.12) 44.1% 44.2%, transparent 44.34% 62%, rgba(255, 43, 255, 0.1) 62.1% 62.22%, transparent 62.34% 78%, rgba(0, 234, 255, 0.12) 78.1% 78.22%, transparent 78.34%), + repeating-linear-gradient(90deg, transparent 0 78px, rgba(0, 234, 255, 0.05) 80px 82px, transparent 84px 116px), + linear-gradient(to top, rgba(3, 12, 27, 0.66) 0%, rgba(3, 12, 27, 0.42) 8%, rgba(3, 12, 27, 0.16) 17%, transparent 28%), + linear-gradient(to top, rgba(5, 20, 44, 0.5) 0%, rgba(5, 20, 44, 0.28) 12%, rgba(5, 20, 44, 0.12) 24%, transparent 36%), + linear-gradient(to top, rgba(4, 17, 38, 0.38) 0%, rgba(4, 17, 38, 0.22) 18%, rgba(4, 17, 38, 0.08) 34%, transparent 48%); + --cyber-background-before-opacity: 0.62; + --cyber-background-before-blend: screen; + --cyber-background-after-bg: + repeating-linear-gradient(90deg, transparent 0 34px, rgba(0, 234, 255, 0.12) 35px 36px, transparent 37px 110px), + repeating-linear-gradient(180deg, transparent 0 28px, rgba(255, 43, 255, 0.1) 29px 30px, transparent 31px 78px), + linear-gradient(90deg, transparent, rgba(0, 234, 255, 0.08), transparent); + --cyber-background-after-opacity: 0.26; + --cyber-background-after-blend: screen; + --cyber-wash-bg: + radial-gradient(circle at 18% 44%, rgba(2, 5, 13, 0.48), rgba(2, 5, 13, 0.22) 34%, transparent 58%), + linear-gradient(90deg, rgba(2, 5, 13, 0.42) 0%, rgba(2, 5, 13, 0.28) 34%, rgba(2, 5, 13, 0.08) 66%, rgba(2, 5, 13, 0.3) 100%), + linear-gradient(180deg, rgba(2, 5, 13, 0.18), rgba(2, 5, 13, 0.08) 58%, rgba(2, 5, 13, 0.92)); + --cyber-gridlines-bg: + linear-gradient(rgba(0, 234, 255, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 234, 255, 0.045) 1px, transparent 1px); + --cyber-gridlines-opacity: 0.16; + --cyber-scanlines-bg: repeating-linear-gradient( + to bottom, + rgba(255, 255, 255, 0.08) 0, + rgba(255, 255, 255, 0.08) 1px, + transparent 1px, + transparent 4px + ); + --cyber-scanlines-opacity: 0.11; + --cyber-copy-aura: radial-gradient(circle at 28% 38%, rgba(2, 5, 13, 0.82), rgba(2, 5, 13, 0.36) 62%, transparent 78%); + --cyber-title-color: rgba(244, 247, 255, 0.96); + --cyber-description-color: rgba(222, 229, 255, 0.84); + --cyber-action-primary-color: var(--cyber-bg-0); + --cyber-action-secondary-bg: rgba(3, 10, 22, 0.56); + --cyber-action-secondary-hover-bg: rgba(0, 234, 255, 0.08); + --cyber-release-color: rgba(244, 247, 255, 0.62); + --cyber-scene-floor-bg: + radial-gradient(ellipse at 58% 84%, rgba(255, 43, 255, 0.24), transparent 18%), + radial-gradient(ellipse at 56% 84%, rgba(0, 234, 255, 0.18), transparent 32%), + repeating-radial-gradient(ellipse at 58% 84%, rgba(0, 234, 255, 0.1) 0 1px, transparent 1px 20px); + --cyber-scene-floor-opacity: 0.48; + --cyber-scene-foreground-bg: + linear-gradient(90deg, transparent 0 4%, rgba(0, 234, 255, 0.08) 4.1%, transparent 4.4%), + linear-gradient(180deg, transparent 0 88%, rgba(255, 43, 255, 0.08)); + --cyber-scene-foreground-opacity: 0.72; + --cyber-video-frame-bg: rgba(2, 6, 16, 0.82); + --cyber-video-content-bg: rgba(2, 6, 16, 0.94); + --cyber-card-text: rgba(244, 247, 255, 0.84); + --cyber-card-muted: rgba(222, 229, 255, 0.72); + --cyber-card-subtle: rgba(222, 229, 255, 0.62); + --cyber-card-inset: rgba(255, 255, 255, 0.06); + --cyber-feature-shell-bg: + radial-gradient(ellipse at 50% 62%, rgba(0, 234, 255, 0.08), transparent 56%), + radial-gradient(ellipse at 78% 52%, rgba(255, 43, 255, 0.08), transparent 48%), + linear-gradient(180deg, transparent 0%, rgba(5, 17, 40, 0.08) 30%, rgba(5, 14, 31, 0.18) 52%, rgba(5, 17, 40, 0.08) 72%, transparent 100%); + --cyber-feature-rail-bg: + linear-gradient(180deg, rgba(3, 10, 22, 0.34) 0%, rgba(5, 14, 31, 0.74) 30%, rgba(5, 14, 31, 0.72) 70%, rgba(3, 10, 22, 0.34) 100%), + linear-gradient(135deg, rgba(5, 14, 31, 0.58), rgba(3, 10, 22, 0.42)); + --cyber-feature-rail-shadow: + 0 0 0 1px rgba(47, 125, 255, 0.1) inset, + 0 -28px 46px rgba(2, 5, 13, 0.14), + 0 30px 58px rgba(2, 5, 13, 0.18), + 0 0 24px rgba(0, 234, 255, 0.1); + --cyber-feature-divider: rgba(0, 234, 255, 0.16); + --cyber-feature-title: rgba(244, 247, 255, 0.94); + --cyber-feature-text: rgba(222, 229, 255, 0.62); --cyber-radius-xs: 4px; --cyber-radius-sm: 6px; --cyber-radius-md: 8px; --cyber-frame-cut: 18px; } +.v-theme--light .cyber-hero { + --cyber-bg-0: #f8fcff; + --cyber-bg-1: #eaf7fb; + --cyber-panel-weak: rgba(255, 255, 255, 0.68); + --cyber-panel: rgba(255, 255, 255, 0.78); + --cyber-panel-strong: rgba(255, 255, 255, 0.92); + --cyber-cyan: #008fb3; + --cyber-blue: #2563eb; + --cyber-magenta: #b832d8; + --cyber-violet: #7c3aed; + --cyber-text: #132238; + --cyber-muted: #596b83; + --cyber-border-cyan: rgba(8, 145, 178, 0.34); + --cyber-border-magenta: rgba(184, 50, 216, 0.28); + --cyber-panel-bg: + linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(237, 249, 252, 0.68)); + --cyber-panel-shadow: + 0 0 0 1px rgba(8, 145, 178, 0.1) inset, + 0 18px 48px rgba(8, 145, 178, 0.12), + 0 0 22px rgba(184, 50, 216, 0.07); + --cyber-hero-bg: + radial-gradient(circle at 76% 30%, rgba(0, 178, 214, 0.14), transparent 32%), + radial-gradient(circle at 86% 70%, rgba(184, 50, 216, 0.13), transparent 36%), + linear-gradient(180deg, #fbfdff 0%, #edf8fb 56%, #f8fcff 100%); + --cyber-monterey-bg: + radial-gradient(circle at 78% 24%, rgba(113, 185, 255, 0.28), transparent 31%), + radial-gradient(circle at 22% 72%, rgba(221, 170, 255, 0.24), transparent 38%), + radial-gradient(circle at 8% 32%, rgba(101, 218, 255, 0.18), transparent 34%), + linear-gradient(180deg, #f8fcff 0%, #eaf7fb 48%, #fbf7ff 100%); + --cyber-monterey-before-bg: + radial-gradient(circle at 18% 34%, rgba(255, 255, 255, 0.46), rgba(255, 255, 255, 0.14) 34%, transparent 62%), + linear-gradient(90deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.08) 42%, rgba(255, 255, 255, 0.03) 64%, rgba(237, 247, 252, 0.2) 100%); + --cyber-monterey-after-bg: + linear-gradient(180deg, rgba(248, 252, 255, 0.52) 0%, rgba(248, 252, 255, 0.22) 18%, rgba(248, 252, 255, 0.04) 48%, rgba(248, 252, 255, 0.58) 100%), + radial-gradient(circle at 64% 42%, transparent 0 26%, rgba(255, 255, 255, 0.16) 70%, rgba(235, 247, 252, 0.42) 100%); + --cyber-monterey-canvas-opacity: 1; + --cyber-monterey-canvas-filter: blur(1px) saturate(1.34) brightness(1.12) contrast(1.16); + --cyber-monterey-canvas-blend: multiply; + --cyber-background-bg: + radial-gradient(circle at 72% 28%, rgba(0, 178, 214, 0.12), transparent 31%), + radial-gradient(circle at 88% 62%, rgba(184, 50, 216, 0.1), transparent 33%), + linear-gradient(90deg, transparent 0 64px, rgba(8, 145, 178, 0.06) 65px 66px, transparent 67px 160px), + linear-gradient(180deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.08) 38%, rgba(237, 247, 252, 0.64) 100%); + --cyber-background-opacity: 0.5; + --cyber-background-before-bg: + linear-gradient(90deg, transparent 0 8%, rgba(8, 145, 178, 0.16) 8.1% 8.22%, transparent 8.34% 18%, rgba(184, 50, 216, 0.12) 18.1% 18.22%, transparent 18.34% 31%, rgba(8, 145, 178, 0.13) 31.1% 31.24%, transparent 31.36% 44%, rgba(37, 99, 235, 0.12) 44.1% 44.2%, transparent 44.34% 62%, rgba(184, 50, 216, 0.1) 62.1% 62.22%, transparent 62.34% 78%, rgba(8, 145, 178, 0.12) 78.1% 78.22%, transparent 78.34%), + repeating-linear-gradient(90deg, transparent 0 78px, rgba(8, 145, 178, 0.06) 80px 82px, transparent 84px 116px), + linear-gradient(to top, rgba(8, 145, 178, 0.12) 0%, rgba(8, 145, 178, 0.07) 13%, transparent 31%), + linear-gradient(to top, rgba(184, 50, 216, 0.1) 0%, rgba(184, 50, 216, 0.05) 16%, transparent 38%), + linear-gradient(to top, rgba(37, 99, 235, 0.09) 0%, rgba(37, 99, 235, 0.04) 20%, transparent 48%); + --cyber-background-before-opacity: 0.58; + --cyber-background-before-blend: multiply; + --cyber-background-after-bg: + repeating-linear-gradient(90deg, transparent 0 34px, rgba(8, 145, 178, 0.1) 35px 36px, transparent 37px 110px), + repeating-linear-gradient(180deg, transparent 0 28px, rgba(184, 50, 216, 0.08) 29px 30px, transparent 31px 78px), + linear-gradient(90deg, transparent, rgba(8, 145, 178, 0.07), transparent); + --cyber-background-after-opacity: 0.18; + --cyber-background-after-blend: multiply; + --cyber-wash-bg: + radial-gradient(circle at 18% 44%, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.12) 36%, transparent 60%), + linear-gradient(90deg, rgba(255, 255, 255, 0.18) 0%, rgba(237, 248, 252, 0.1) 36%, rgba(255, 255, 255, 0.03) 68%, rgba(251, 247, 255, 0.16) 100%), + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(248, 252, 255, 0.04) 58%, rgba(248, 252, 255, 0.72)); + --cyber-gridlines-bg: + linear-gradient(rgba(8, 145, 178, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(8, 145, 178, 0.045) 1px, transparent 1px); + --cyber-gridlines-opacity: 0.2; + --cyber-scanlines-bg: repeating-linear-gradient( + to bottom, + rgba(8, 35, 50, 0.035) 0, + rgba(8, 35, 50, 0.035) 1px, + transparent 1px, + transparent 4px + ); + --cyber-scanlines-opacity: 0.08; + --cyber-copy-aura: radial-gradient(circle at 28% 38%, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.38) 58%, transparent 78%); + --cyber-title-color: rgba(19, 34, 56, 0.98); + --cyber-description-color: rgba(50, 65, 88, 0.82); + --cyber-action-primary-color: #061722; + --cyber-action-secondary-bg: rgba(255, 255, 255, 0.62); + --cyber-action-secondary-hover-bg: rgba(8, 145, 178, 0.08); + --cyber-release-color: rgba(50, 65, 88, 0.68); + --cyber-scene-floor-bg: + radial-gradient(ellipse at 58% 84%, rgba(184, 50, 216, 0.16), transparent 18%), + radial-gradient(ellipse at 56% 84%, rgba(8, 145, 178, 0.14), transparent 32%), + repeating-radial-gradient(ellipse at 58% 84%, rgba(8, 145, 178, 0.08) 0 1px, transparent 1px 20px); + --cyber-scene-floor-opacity: 0.38; + --cyber-scene-foreground-bg: + linear-gradient(90deg, transparent 0 4%, rgba(8, 145, 178, 0.07) 4.1%, transparent 4.4%), + linear-gradient(180deg, transparent 0 88%, rgba(184, 50, 216, 0.06)); + --cyber-scene-foreground-opacity: 0.48; + --cyber-video-frame-bg: rgba(255, 255, 255, 0.7); + --cyber-video-content-bg: rgba(8, 20, 34, 0.9); + --cyber-card-text: rgba(19, 34, 56, 0.88); + --cyber-card-muted: rgba(67, 82, 105, 0.76); + --cyber-card-subtle: rgba(67, 82, 105, 0.64); + --cyber-card-inset: rgba(255, 255, 255, 0.62); + --cyber-feature-shell-bg: transparent; + --cyber-feature-rail-bg: transparent; + --cyber-feature-rail-shadow: + 0 0 0 1px rgba(8, 145, 178, 0.12) inset, + 0 -1px 0 rgba(8, 145, 178, 0.18), + 0 1px 0 rgba(8, 145, 178, 0.16); + --cyber-feature-divider: rgba(8, 145, 178, 0.18); + --cyber-feature-title: rgba(19, 34, 56, 0.94); + --cyber-feature-text: rgba(67, 82, 105, 0.72); +} + .cyber-panel { position: relative; border: 1px solid var(--cyber-border-cyan); - background: - linear-gradient(135deg, rgba(5, 14, 31, 0.9), rgba(3, 10, 22, 0.64)); + background: var(--cyber-panel-bg); clip-path: polygon( var(--cyber-frame-cut) 0, 100% 0, @@ -33,9 +234,7 @@ 0 100%, 0 var(--cyber-frame-cut) ); - box-shadow: - 0 0 0 1px rgba(47, 125, 255, 0.12) inset, - 0 0 24px rgba(0, 234, 255, 0.12); + box-shadow: var(--cyber-panel-shadow); } .cyber-hero { @@ -53,12 +252,11 @@ isolation: isolate; overflow: clip; color: var(--cyber-text); - background: - radial-gradient(circle at 72% 24%, rgba(0, 234, 255, 0.15), transparent 34%), - linear-gradient(180deg, var(--cyber-bg-0), var(--cyber-bg-1) 58%, var(--cyber-bg-0)); + background: var(--cyber-hero-bg); } .cyber-hero__background, +.cyber-hero__monterey, .cyber-hero__wash, .cyber-hero__gridlines, .cyber-hero__scanlines { @@ -67,63 +265,134 @@ pointer-events: none; } +.cyber-hero__monterey { + z-index: -5; + overflow: hidden; + background: var(--cyber-monterey-bg); +} + +.cyber-hero__monterey::before, +.cyber-hero__monterey::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; +} + +.cyber-hero__monterey::before { + z-index: 2; + background: var(--cyber-monterey-before-bg); +} + +.cyber-hero__monterey::after { + z-index: 3; + background: var(--cyber-monterey-after-bg); +} + +.cyber-hero__monterey-canvas { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + opacity: 0; + filter: var(--cyber-monterey-canvas-filter); + mix-blend-mode: var(--cyber-monterey-canvas-blend); + transform: scale(1.035); + transition: opacity 1.65s cubic-bezier(0.22, 0.72, 0.2, 1); +} + +.cyber-hero__monterey--live .cyber-hero__monterey-canvas { + opacity: var(--cyber-monterey-canvas-opacity); +} + +.cyber-hero__monterey a[data-n] { + left: 14px !important; + right: auto !important; + bottom: 12px !important; + z-index: 5 !important; + padding: 0 !important; + color: rgba(244, 247, 255, 0.46) !important; + font: 700 10px/1 var(--font-family-mono, monospace) !important; + letter-spacing: 0.14em !important; + opacity: 0.32 !important; + text-shadow: none !important; +} + .cyber-hero__background { z-index: -4; inset: -40px -40px -80px; - background-image: url("~/assets/images/hero/backgrounds/cyber-city-desktop-v1.webp"); - background-size: cover; - background-position: 58% top; - opacity: 1; + background: var(--cyber-background-bg); + background-size: + auto, + auto, + 220px 100%, + auto; + background-position: + 0 0, + center, + 24px 0, + center; + opacity: var(--cyber-background-opacity); transform: translate3d( - calc(var(--hero-pointer-x) * -8px), - calc(var(--hero-scroll) * 0.035px + var(--hero-pointer-y) * -5px), + 0, + calc(var(--hero-scroll) * 0.035px), 0 ) scale(1.035); will-change: transform; } +.cyber-hero__background::before { + content: ""; + position: absolute; + inset: 12% 0 0; + background: var(--cyber-background-before-bg); + background-size: + auto, + auto, + 190px 100%, + 148px 100%, + 116px 100%; + background-position: + 0 0, + 0 0, + 16px bottom, + 72px bottom, + 120px bottom; + opacity: var(--cyber-background-before-opacity); + mix-blend-mode: var(--cyber-background-before-blend); + mask-image: linear-gradient(180deg, transparent 0, black 22%, black 78%, transparent 100%); +} + .cyber-hero__background::after { content: ""; position: absolute; inset: 0; - background-image: url("~/assets/images/hero/backgrounds/cyber-city-desktop-v1.webp"); - background-size: cover; - background-position: right top; - opacity: 0.78; - transform: scaleX(-1); - filter: saturate(1.08) contrast(1.12) brightness(0.72); - mix-blend-mode: screen; - mask-image: linear-gradient(90deg, black 0 16%, rgba(0, 0, 0, 0.72) 38%, transparent 64%); + background: var(--cyber-background-after-bg); + opacity: var(--cyber-background-after-opacity); + mix-blend-mode: var(--cyber-background-after-blend); + mask-image: + linear-gradient(180deg, transparent 0 10%, black 22%, black 82%, transparent 100%), + linear-gradient(90deg, transparent 0 8%, black 24%, black 94%, transparent 100%); } .cyber-hero__wash { z-index: -3; - background: - radial-gradient(circle at 18% 44%, rgba(2, 5, 13, 0.48), rgba(2, 5, 13, 0.22) 34%, transparent 58%), - linear-gradient(90deg, rgba(2, 5, 13, 0.42) 0%, rgba(2, 5, 13, 0.28) 34%, rgba(2, 5, 13, 0.08) 66%, rgba(2, 5, 13, 0.3) 100%), - linear-gradient(180deg, rgba(2, 5, 13, 0.18), rgba(2, 5, 13, 0.08) 58%, rgba(2, 5, 13, 0.92)); + background: var(--cyber-wash-bg); } .cyber-hero__gridlines { z-index: -2; - opacity: 0.34; - background-image: - linear-gradient(rgba(0, 234, 255, 0.055) 1px, transparent 1px), - linear-gradient(90deg, rgba(0, 234, 255, 0.045) 1px, transparent 1px); + opacity: var(--cyber-gridlines-opacity); + background-image: var(--cyber-gridlines-bg); background-size: 72px 72px; mask-image: linear-gradient(180deg, transparent, black 12%, black 72%, transparent); } .cyber-hero__scanlines { z-index: 8; - opacity: 0.11; - background-image: repeating-linear-gradient( - to bottom, - rgba(255, 255, 255, 0.08) 0, - rgba(255, 255, 255, 0.08) 1px, - transparent 1px, - transparent 4px - ); + opacity: var(--cyber-scanlines-opacity); + background-image: var(--cyber-scanlines-bg); mix-blend-mode: overlay; } @@ -131,7 +400,8 @@ position: relative; z-index: 2; width: min(1580px, calc(100vw - 56px)); - max-width: none !important; + max-width: min(1580px, calc(100vw - 56px)) !important; + margin-inline: auto; } .cyber-hero__layout { @@ -144,18 +414,13 @@ .cyber-hero__copy { position: relative; z-index: 6; - width: min(620px, 41vw); - max-width: 620px; + width: min(760px, 49vw); + max-width: 760px; padding: 24px 0 24px; } .cyber-hero__copy::before { - content: ""; - position: absolute; - inset: -64px -64px -42px -36px; - z-index: -1; - background: radial-gradient(circle at 28% 38%, rgba(2, 5, 13, 0.82), rgba(2, 5, 13, 0.36) 62%, transparent 78%); - pointer-events: none; + display: none; } .cyber-hero__brand-lockup { @@ -186,14 +451,15 @@ .cyber-hero__title { margin: 0 0 22px; display: flex; - flex-direction: column; - flex-wrap: wrap; - gap: 0; - font-size: clamp(4.2rem, 6.3vw, 6.25rem); - line-height: 0.95; + flex-direction: row; + flex-wrap: nowrap; + gap: 0.18em; + font-size: clamp(2.55rem, 5.1vw, 5.7rem); + line-height: 1; font-weight: 900; letter-spacing: 0; - color: rgba(244, 247, 255, 0.96); + color: var(--cyber-title-color); + white-space: nowrap; } .cyber-hero__title-accent { @@ -220,7 +486,7 @@ .cyber-hero__description { max-width: 560px; margin: 0 0 30px; - color: rgba(222, 229, 255, 0.84); + color: var(--cyber-description-color); font-size: clamp(1rem, 1.08vw, 1.22rem); line-height: 1.7; } @@ -253,7 +519,7 @@ } .cyber-hero__action--primary.v-btn { - color: var(--cyber-bg-0) !important; + color: var(--cyber-action-primary-color) !important; background: linear-gradient(135deg, var(--cyber-cyan), var(--cyber-magenta)) !important; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16) inset, @@ -265,41 +531,28 @@ .cyber-hero__action--docs.v-btn { color: var(--cyber-text) !important; border-color: rgba(0, 234, 255, 0.46) !important; - background: rgba(3, 10, 22, 0.56) !important; + background: var(--cyber-action-secondary-bg) !important; } .cyber-hero__action--watch.v-btn:hover, .cyber-hero__action--docs.v-btn:hover { color: var(--cyber-cyan) !important; border-color: rgba(0, 234, 255, 0.74) !important; - background: rgba(0, 234, 255, 0.08) !important; + background: var(--cyber-action-secondary-hover-bg) !important; } .cyber-hero__terminal-note { - display: flex; + display: inline-flex; align-items: center; - justify-content: space-between; - gap: 16px; - width: min(100%, 560px); - min-height: 66px; - padding: 13px 18px; + justify-content: flex-start; + width: auto; + min-height: 0; + margin: 0; + padding: 7px 12px; color: var(--cyber-cyan); - text-decoration: none; font-family: var(--at-font-mono); font-size: 0.74rem; - line-height: 1.55; - transition: - border-color 0.18s ease, - color 0.18s ease, - box-shadow 0.18s ease; -} - -.cyber-hero__terminal-note:hover { - color: var(--cyber-amber); - border-color: rgba(255, 178, 56, 0.58); - box-shadow: - 0 0 0 1px rgba(255, 178, 56, 0.14) inset, - 0 0 28px rgba(255, 178, 56, 0.16); + line-height: 1.2; } .cyber-hero__terminal-lines { @@ -309,37 +562,39 @@ .cyber-hero__release { flex: 0 0 auto; - color: rgba(244, 247, 255, 0.62); + color: var(--cyber-release-color); white-space: nowrap; } +.cyber-hero__release-date { + color: var(--cyber-card-subtle); +} + .cyber-hero__scene { min-width: 0; - max-width: 1120px; + width: clamp(940px, 60vw, 1220px); + max-width: none; margin-left: clamp(-220px, -12vw, -130px); margin-top: -68px; margin-right: 0; } .cyber-scene { + --scene-video-left: 29.33%; + --scene-video-top: 30%; + --scene-video-width: 45.33%; + --scene-video-height: 25.5cqw; + --scene-platform-y: 82%; + position: relative; isolation: isolate; + container-type: inline-size; aspect-ratio: 16 / 9; min-height: 600px; - transform: - translate3d( - calc(var(--hero-pointer-x) * 12px), - calc(var(--hero-pointer-y) * 8px), - 0 - ) - rotateX(calc(var(--hero-tilt-y) * 0.65deg)) - rotateY(calc(var(--hero-tilt-x) * -0.9deg)); transform-style: preserve-3d; - will-change: transform; } .cyber-scene__floor, -.cyber-scene__connectors, .cyber-scene__robots, .cyber-scene__messages, .cyber-scene__foreground { @@ -350,30 +605,33 @@ .cyber-scene__floor { z-index: 0; - background: - radial-gradient(ellipse at 58% 84%, rgba(255, 43, 255, 0.38), transparent 20%), - radial-gradient(ellipse at 56% 84%, rgba(0, 234, 255, 0.27), transparent 36%), - repeating-radial-gradient(ellipse at 58% 84%, rgba(0, 234, 255, 0.2) 0 1px, transparent 1px 18px); - filter: blur(7px); - opacity: 0.95; + background: var(--cyber-scene-floor-bg); + filter: blur(8px); + opacity: var(--cyber-scene-floor-opacity); + mask-image: radial-gradient(ellipse at 58% 84%, black 0 26%, rgba(0, 0, 0, 0.52) 40%, transparent 62%); } -.cyber-scene__connectors { - z-index: 1; +.cyber-scene__floor::before { + content: ""; + position: absolute; + left: 37%; + right: 12%; + top: calc(var(--scene-platform-y) - 2.5%); + height: 9%; + background: + linear-gradient(90deg, transparent 0 9%, rgba(255, 178, 56, 0.34) 9.5% 36%, transparent 36.5% 48%, rgba(255, 178, 56, 0.3) 49% 86%, transparent 86.5%), + linear-gradient(90deg, transparent 0 8%, rgba(0, 234, 255, 0.26) 8.5% 42%, transparent 42.5% 48%, rgba(0, 234, 255, 0.22) 49% 88%, transparent 88.5%); + clip-path: polygon(8% 58%, 28% 28%, 43% 54%, 49% 54%, 68% 26%, 94% 58%, 92% 68%, 68% 40%, 50% 68%, 43% 68%, 28% 40%, 10% 68%); + filter: drop-shadow(0 0 16px rgba(255, 178, 56, 0.22)); + opacity: 0.72; } .cyber-scene__video { position: absolute; z-index: 3; - left: 24%; - top: 27%; - width: 59%; - transform: - translate3d( - calc(var(--hero-pointer-x) * 8px), - calc(var(--hero-pointer-y) * 5px), - 0 - ); + left: var(--scene-video-left); + top: var(--scene-video-top); + width: var(--scene-video-width); pointer-events: auto; } @@ -387,10 +645,10 @@ .cyber-scene__foreground { z-index: 6; - background: - linear-gradient(90deg, transparent 0 4%, rgba(0, 234, 255, 0.08) 4.1%, transparent 4.4%), - linear-gradient(180deg, transparent 0 88%, rgba(255, 43, 255, 0.08)); + background: var(--cyber-scene-foreground-bg); mix-blend-mode: screen; + opacity: var(--cyber-scene-foreground-opacity); + mask-image: linear-gradient(180deg, transparent 0 18%, black 28%, black 78%, transparent 100%); } .cyber-video-frame { @@ -399,7 +657,7 @@ position: relative; aspect-ratio: 16 / 9; border: 1px solid rgba(0, 234, 255, 0.66); - background: rgba(2, 6, 16, 0.82); + background: var(--cyber-video-frame-bg); clip-path: polygon(20px 0, 100% 0, 100% calc(100% - 20px), calc(100% - 20px) 100%, 0 100%, 0 20px); box-shadow: 0 0 0 1px rgba(47, 125, 255, 0.2) inset, @@ -442,7 +700,7 @@ z-index: 2; overflow: hidden; border-radius: var(--cyber-radius-sm); - background: rgba(2, 6, 16, 0.94); + background: var(--cyber-video-content-bg); clip-path: polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px); } @@ -452,7 +710,7 @@ border: 0; border-radius: 0; box-shadow: none; - background: rgba(2, 6, 16, 0.95); + background: var(--cyber-video-content-bg); } .cyber-video-frame__content .hero-video__player { @@ -514,14 +772,9 @@ position: absolute; left: calc(var(--agent-x) * 1%); top: calc(var(--agent-y) * 1%); - width: 168px; + width: 156px; transform: - translate3d(-50%, -50%, 0) - translate3d( - calc(var(--hero-pointer-x) * var(--agent-depth) * 18px), - calc(var(--hero-pointer-y) * var(--agent-depth) * 14px), - 0 - ) + translate3d(-50%, -100%, 0) scale(var(--agent-scale)); transform-origin: center bottom; transition: filter 0.2s ease; @@ -546,16 +799,17 @@ .cyber-agent__float { position: relative; + transform-origin: center bottom; animation: cyberRobotBob calc(5.2s + var(--agent-depth) * 1.8s) ease-in-out infinite; animation-delay: calc(var(--agent-depth) * -2.4s); } .cyber-agent__contact { position: absolute; - left: 18%; - right: 18%; - bottom: -8px; - height: 22px; + left: 17%; + right: 17%; + bottom: 18px; + height: 14px; border-radius: 50%; background: var(--agent-accent-soft); filter: blur(10px); @@ -567,10 +821,12 @@ display: block; width: 100%; height: auto; + transform: scaleX(var(--agent-face)) rotate(var(--agent-lean)); + transform-origin: center bottom; user-select: none; filter: - drop-shadow(0 14px 24px rgba(0, 0, 0, 0.54)) - drop-shadow(0 0 16px var(--agent-accent-soft)); + drop-shadow(0 12px 20px rgba(0, 0, 0, 0.52)) + drop-shadow(0 0 14px var(--agent-accent-soft)); } .cyber-agent__eyes { @@ -591,33 +847,49 @@ .cyber-agent__card { position: absolute; z-index: 4; - top: 20%; - left: 72%; - width: 190px; - padding: 11px 13px; - color: rgba(244, 247, 255, 0.84); + top: 4%; + left: 66%; + width: 274px; + padding: 12px 13px; + color: var(--cyber-card-text); font-family: var(--at-font-mono); - font-size: 0.62rem; - line-height: 1.45; + font-size: 1.08rem; + line-height: 1.28; border-color: color-mix(in srgb, var(--agent-accent), transparent 44%); box-shadow: - 0 0 0 1px rgba(255, 255, 255, 0.06) inset, + 0 0 0 1px var(--cyber-card-inset) inset, 0 0 24px var(--agent-accent-soft); } .cyber-agent--card-left .cyber-agent__card { - right: 72%; + right: 64%; left: auto; } .cyber-agent--card-bottom .cyber-agent__card { - top: 78%; + top: calc(100% + 8px); left: 50%; + width: 200px; + padding: 11px 12px; + font-size: 0.74rem; transform: translateX(-50%); } +.cyber-agent[data-agent="docs"].cyber-agent--card-bottom .cyber-agent__card, +.cyber-agent[data-agent="ops"].cyber-agent--card-bottom .cyber-agent__card, +.cyber-agent[data-agent="security"].cyber-agent--card-bottom .cyber-agent__card, +.cyber-agent[data-agent="fixer"].cyber-agent--card-bottom .cyber-agent__card { + top: calc(100% + 10px); + opacity: 0.82; +} + +.cyber-agent--card-bottom .cyber-agent__status { + margin-top: 5px; + justify-content: center; +} + .cyber-agent__label { - margin-bottom: 6px; + margin-bottom: 7px; color: var(--agent-accent); font-weight: 800; text-transform: uppercase; @@ -626,7 +898,7 @@ .cyber-agent__tasks { display: grid; - gap: 3px; + gap: 4px; margin: 0; padding: 0; list-style: none; @@ -634,8 +906,8 @@ .cyber-agent__tasks li { position: relative; - padding-left: 12px; - color: rgba(222, 229, 255, 0.72); + padding-left: 15px; + color: var(--cyber-card-muted); } .cyber-agent__tasks li::before { @@ -643,17 +915,17 @@ position: absolute; left: 0; top: 0.52em; - width: 5px; - height: 5px; + width: 6px; + height: 6px; border: 1px solid var(--agent-accent); transform: rotate(45deg); } .cyber-agent__status { display: flex; - gap: 5px; + gap: 6px; margin-top: 7px; - color: rgba(222, 229, 255, 0.62); + color: var(--cyber-card-subtle); } .cyber-agent__status strong { @@ -661,6 +933,82 @@ font-weight: 800; } +.cyber-agent[data-agent="planner"] { + z-index: 7; + width: 150px; + transform: + translate3d(-69%, -56%, 0) + scale(var(--agent-scale)); +} + +.cyber-agent[data-agent="planner"] .cyber-agent__float { + transform-origin: center 80%; +} + +.cyber-agent[data-agent="planner"] .cyber-agent__image { + transform: + scaleX(1) + rotate(-4deg); +} + +.cyber-agent[data-agent="planner"] .cyber-agent__contact { + left: 23%; + right: 16%; + bottom: 36%; + height: 12px; +} + +.cyber-agent[data-agent="planner"] .cyber-agent__card { + display: block; + top: 10%; + right: 106%; + left: auto; + width: 258px; + padding: 11px 12px; + font-size: 1.04rem; + transform: translate(-28px, -8%); +} + +.cyber-agent[data-agent="lead"] .cyber-agent__card { + top: -14%; + right: 104%; + left: auto; + width: 274px; + transform: translateY(-12px); +} + +.cyber-agent[data-agent="developer"] .cyber-agent__card { + top: -14%; + left: 98%; + width: 274px; + transform: translateY(-12px); +} + +@media (min-width: 1101px) { + .cyber-agent[data-agent="planner"] { + left: calc(var(--agent-x) * 1% + 20px); + top: calc(var(--agent-y) * 1% - 26px); + } + + .cyber-agent[data-agent="lead"], + .cyber-agent[data-agent="developer"] { + top: calc(var(--agent-y) * 1% - 4px); + } + + .cyber-agent[data-agent="lead"] .cyber-agent__float, + .cyber-agent[data-agent="developer"] .cyber-agent__float { + top: 10px; + } + + .cyber-agent[data-agent="planner"] .cyber-agent__float { + top: 70px; + } +} + +.cyber-agent[data-agent="planner"] .cyber-agent__eyes { + display: none; +} + .cyber-agent--sending, .cyber-agent--receiving { filter: brightness(1.18); @@ -672,96 +1020,73 @@ animation: cyberPanelPulse 1.3s ease-in-out infinite; } -.cyber-connectors { - display: block; - width: 100%; - height: 100%; - overflow: visible; -} - -.cyber-connectors__path, -.cyber-connectors__path-glow { - fill: none; -} - -.cyber-connectors__path { - stroke: rgba(0, 234, 255, 0.36); - stroke-width: 1.2; - stroke-linecap: round; - stroke-linejoin: round; -} - -.cyber-connectors__path-glow { - stroke: rgba(0, 234, 255, 0.14); - stroke-width: 6; - stroke-linecap: round; - stroke-linejoin: round; -} - -.cyber-connectors__path--magenta { - stroke: rgba(255, 43, 255, 0.34); -} - -.cyber-connectors__path-glow--magenta { - stroke: rgba(255, 43, 255, 0.16); -} - -.cyber-connectors__path--amber { - stroke: rgba(255, 178, 56, 0.34); -} - -.cyber-connectors__path-glow--amber { - stroke: rgba(255, 178, 56, 0.16); -} - -.cyber-connectors__path--active { - stroke: rgba(255, 43, 255, 0.92); - stroke-width: 1.8; -} - -.cyber-connectors__path-glow--active { - stroke: rgba(255, 43, 255, 0.38); - stroke-width: 9; -} - -.cyber-connectors__packet { - fill: var(--cyber-cyan); - filter: drop-shadow(0 0 8px rgba(0, 234, 255, 0.86)); - opacity: 0.74; -} - -.cyber-connectors__packet--magenta { - fill: var(--cyber-magenta); - filter: drop-shadow(0 0 8px rgba(255, 43, 255, 0.86)); -} - -.cyber-connectors__packet--amber { - fill: var(--cyber-amber); - filter: drop-shadow(0 0 8px rgba(255, 178, 56, 0.86)); -} - -.cyber-connectors__packet--active { - r: 5; - opacity: 1; -} - .cyber-message { + --speech-fill: rgba(255, 246, 159, 0.92); + --speech-glow: rgba(255, 215, 0, 0.18); + position: absolute; left: calc(var(--bubble-x) * 1%); top: calc(var(--bubble-y) * 1%); - max-width: 196px; - padding: 10px 13px; - transform: translate3d(-50%, -50%, 0); - color: var(--cyber-cyan); + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + max-width: 168px; + min-height: 30px; + padding: 7px 11px; + color: #0b1020; font-family: var(--at-font-mono); - font-size: 0.72rem; - line-height: 1.35; - text-shadow: 0 0 12px rgba(0, 234, 255, 0.32); + font-size: 0.64rem; + font-weight: 900; + line-height: 1.1; + letter-spacing: 0; + text-align: center; + white-space: nowrap; + background: + radial-gradient(circle at 28% 24%, rgba(255, 255, 255, 0.9), var(--speech-fill) 64%, rgba(255, 224, 88, 0.92) 100%); + border: 2px solid #050816; + border-radius: 999px; + box-shadow: + 0 0 0 1px rgba(255, 215, 0, 0.36), + 0 5px 0 rgba(0, 0, 0, 0.2), + 0 0 14px var(--speech-glow); + text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62); + transform: translate3d(-50%, -50%, 0) rotate(-4deg); + transform-origin: center bottom; + animation: cyberSpeechFloat 2.8s ease-in-out 0.45s infinite; +} + +.cyber-message::before, +.cyber-message::after { + position: absolute; + top: auto; + right: 42%; + content: ""; + clip-path: polygon(0 0, 100% 0, 50% 100%); + transform: none; +} + +.cyber-message::before { + bottom: -26px; + width: 22px; + height: 27px; + background: #050816; +} + +.cyber-message::after { + bottom: -20px; + width: 16px; + height: 21px; + background: rgba(255, 226, 78, 0.96); } .cyber-message--receiver { - color: var(--cyber-amber); - border-color: rgba(255, 178, 56, 0.5); + --speech-fill: rgba(198, 249, 255, 0.94); + --speech-glow: rgba(0, 234, 255, 0.18); +} + +.cyber-message--receiver::after { + background: rgba(198, 249, 255, 0.96); } .cyber-message--static { @@ -772,25 +1097,289 @@ .cyber-bubble-enter-active, .cyber-bubble-leave-active { transition: - opacity 0.24s ease, - transform 0.24s ease; + opacity 0.22s ease, + filter 0.22s ease; +} + +.cyber-bubble-enter-active { + animation: cyberSpeechPop 0.46s cubic-bezier(0.18, 0.9, 0.2, 1.22); } .cyber-bubble-enter-from, .cyber-bubble-leave-to { opacity: 0; - transform: translate3d(-50%, calc(-50% + 10px), 0); + filter: blur(2px); +} + +.cyber-bubble-leave-active { + animation: cyberSpeechExit 0.2s ease forwards; +} + +@keyframes cyberSpeechPop { + 0% { + opacity: 0; + transform: translate3d(-50%, calc(-50% + 12px), 0) scale(0.54) rotate(-12deg); + } + + 62% { + opacity: 1; + transform: translate3d(-50%, calc(-50% - 2px), 0) scale(1.08) rotate(-3deg); + } + + 100% { + opacity: 1; + transform: translate3d(-50%, -50%, 0) scale(1) rotate(-4deg); + } +} + +@keyframes cyberSpeechFloat { + 0%, + 100% { + transform: translate3d(-50%, -50%, 0) rotate(-4deg); + } + + 50% { + transform: translate3d(-50%, calc(-50% - 3px), 0) rotate(-3deg); + } +} + +@keyframes cyberSpeechExit { + to { + opacity: 0; + transform: translate3d(-50%, calc(-50% + 8px), 0) scale(0.86) rotate(-8deg); + } +} + +@keyframes cyberFeatureReviewerSpeechFloat { + 0%, + 100% { + transform: translateX(calc(50% + var(--reviewer-bubble-center-shift, 0px))) translate3d(0, 0, 0) rotate(-4deg); + } + + 50% { + transform: translateX(calc(50% + var(--reviewer-bubble-center-shift, 0px))) translate3d(0, -3px, 0) rotate(-3deg); + } +} + +@keyframes cyberFeatureReviewerSpeechPop { + 0% { + opacity: 0; + transform: translateX(calc(50% + var(--reviewer-bubble-center-shift, 0px))) translate3d(12px, 12px, 0) scale(0.54) rotate(-12deg); + } + + 62% { + opacity: 1; + transform: translateX(calc(50% + var(--reviewer-bubble-center-shift, 0px))) translate3d(-2px, -3px, 0) scale(1.08) rotate(-3deg); + } + + 100% { + opacity: 1; + transform: translateX(calc(50% + var(--reviewer-bubble-center-shift, 0px))) translate3d(0, 0, 0) scale(1) rotate(-4deg); + } +} + +@keyframes cyberFeatureReviewerSpeechExit { + to { + opacity: 0; + transform: translateX(calc(50% + var(--reviewer-bubble-center-shift, 0px))) translate3d(8px, 8px, 0) scale(0.86) rotate(-8deg); + } +} + +.cyber-feature-rail-shell { + position: relative; + z-index: 7; + margin: 28px auto 0; + width: min(1540px, 96%); +} + +.cyber-feature-rail-shell::before { + content: ""; + position: absolute; + z-index: -1; + inset: -118px -3vw -96px; + pointer-events: none; + background: var(--cyber-feature-shell-bg); + opacity: 0.86; + mask-image: radial-gradient(ellipse at 50% 54%, black 0 48%, rgba(0, 0, 0, 0.5) 62%, transparent 78%); } .cyber-feature-rail { position: relative; - z-index: 7; + z-index: 1; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; - margin: 28px auto 0; - width: min(1540px, 96%); + width: 100%; padding: 17px 18px; + background: var(--cyber-feature-rail-bg); + box-shadow: var(--cyber-feature-rail-shadow); +} + +.cyber-feature-rail__collaboration { + position: absolute; + left: 50%; + bottom: calc(100% - 16px); + z-index: 2; + width: clamp(132px, 11vw, 168px); + height: auto; + pointer-events: none; + user-select: none; + filter: + drop-shadow(0 18px 24px rgba(0, 0, 0, 0.5)) + drop-shadow(0 0 20px rgba(120, 58, 255, 0.18)) + drop-shadow(0 0 18px rgba(255, 45, 64, 0.14)); + transform: translate3d(-50%, 0, 0); +} + +.cyber-feature-rail__reviewer { + --reviewer-robot-width: clamp(76px, 5.4vw, 96px); + + position: absolute; + z-index: 3; + right: clamp(52px, 5vw, 82px); + bottom: calc(100% - 6px); + display: flex; + align-items: flex-end; + gap: 8px; + pointer-events: none; +} + +.cyber-feature-rail__reviewer--active .cyber-feature-rail__reviewer-card { + border-color: rgba(0, 234, 255, 0.86); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.08) inset, + 0 0 28px rgba(0, 234, 255, 0.26); +} + +.cyber-feature-rail__reviewer--active .cyber-feature-rail__robot { + filter: + drop-shadow(0 16px 24px rgba(0, 0, 0, 0.5)) + drop-shadow(0 0 22px rgba(0, 234, 255, 0.36)); +} + +.cyber-feature-rail__reviewer-bubble { + --reviewer-bubble-center-shift: 3px; + --robot-bubble-position: absolute; + --robot-bubble-min-width: 112px; + --robot-bubble-max-width: 184px; + --robot-bubble-min-height: 46px; + --robot-bubble-font-size: 0.64rem; + --robot-bubble-padding: 8px 14px 16px; + + left: auto; + top: auto; + right: calc(var(--reviewer-robot-width) / 2); + bottom: calc(100% + 10px); + transform: translateX(calc(50% + var(--reviewer-bubble-center-shift))) translate3d(0, 0, 0) rotate(-4deg); + transform-origin: center bottom; + animation: cyberFeatureReviewerSpeechFloat 2.8s ease-in-out 0.45s infinite; +} + +.cyber-feature-bubble-enter-active, +.cyber-feature-bubble-leave-active { + transition: + opacity 0.22s ease, + filter 0.22s ease; +} + +.cyber-feature-bubble-enter-active { + animation: cyberFeatureReviewerSpeechPop 0.46s cubic-bezier(0.18, 0.9, 0.2, 1.22); +} + +.cyber-feature-bubble-enter-from, +.cyber-feature-bubble-leave-to { + opacity: 0; + filter: blur(2px); +} + +.cyber-feature-bubble-leave-active { + animation: cyberFeatureReviewerSpeechExit 0.2s ease forwards; +} + +.cyber-feature-rail__robot { + position: relative; + top: 9px; + width: var(--reviewer-robot-width); + height: auto; + transform: rotate(3deg); + transform-origin: center bottom; + filter: + drop-shadow(0 14px 22px rgba(0, 0, 0, 0.48)) + drop-shadow(0 0 16px rgba(0, 234, 255, 0.22)); + pointer-events: none; + user-select: none; + animation: cyberRobotBob 6.4s ease-in-out infinite; +} + +.cyber-feature-rail__reviewer-card { + position: relative; + width: clamp(146px, 10.2vw, 160px); + padding: 7px 8px; + color: var(--cyber-card-text); + font-family: var(--at-font-mono); + font-size: clamp(0.48rem, 0.46vw, 0.54rem); + line-height: 1.28; + border-color: rgba(0, 234, 255, 0.52); + box-shadow: + 0 0 0 1px var(--cyber-card-inset) inset, + 0 0 24px rgba(0, 234, 255, 0.18); + transform: translateY(-34px); +} + +.cyber-feature-rail__reviewer-card::after { + content: ""; + position: absolute; + left: calc(100% - 1px); + top: 56%; + width: 13px; + height: 1px; + background: linear-gradient(90deg, rgba(0, 234, 255, 0.72), transparent); + box-shadow: 0 0 12px rgba(0, 234, 255, 0.3); +} + +.cyber-feature-rail__reviewer-label { + margin-bottom: 5px; + color: var(--cyber-cyan); + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.cyber-feature-rail__reviewer-tasks { + display: grid; + gap: 3px; + margin: 0; + padding: 0; + list-style: none; +} + +.cyber-feature-rail__reviewer-tasks li { + position: relative; + padding-left: 12px; + color: var(--cyber-card-muted); +} + +.cyber-feature-rail__reviewer-tasks li::before { + content: ""; + position: absolute; + left: 0; + top: 0.52em; + width: 5px; + height: 5px; + border: 1px solid var(--cyber-cyan); + transform: rotate(45deg); +} + +.cyber-feature-rail__reviewer-status { + display: flex; + gap: 5px; + margin-top: 5px; + color: var(--cyber-card-subtle); +} + +.cyber-feature-rail__reviewer-status strong { + color: var(--cyber-cyan); + font-weight: 800; } .cyber-feature-rail__item { @@ -800,7 +1389,7 @@ gap: 12px; min-width: 0; padding: 0 18px; - border-right: 1px solid rgba(0, 234, 255, 0.16); + border-right: 1px solid var(--cyber-feature-divider); } .cyber-feature-rail__item:last-child { @@ -820,13 +1409,13 @@ .cyber-feature-rail__title { margin-bottom: 3px; - color: rgba(244, 247, 255, 0.94); + color: var(--cyber-feature-title); font-weight: 800; font-size: 0.92rem; } .cyber-feature-rail__text { - color: rgba(222, 229, 255, 0.62); + color: var(--cyber-feature-text); font-size: 0.8rem; line-height: 1.45; } @@ -834,10 +1423,10 @@ @keyframes cyberRobotBob { 0%, 100% { - transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0) rotate(-0.55deg); } 50% { - transform: translate3d(0, -6px, 0); + transform: translate3d(1px, 0, 0) rotate(0.75deg); } } @@ -874,12 +1463,12 @@ @media (max-width: 1280px) { .cyber-hero__layout { - grid-template-columns: minmax(380px, 0.74fr) minmax(0, 1.26fr); + grid-template-columns: minmax(500px, 0.9fr) minmax(0, 1.1fr); gap: 20px; } .cyber-hero__title { - font-size: clamp(3.35rem, 5.2vw, 5.6rem); + font-size: clamp(3rem, 4.6vw, 4.8rem); } .cyber-scene { @@ -887,8 +1476,8 @@ } .cyber-agent__card { - width: 158px; - font-size: 0.56rem; + width: 274px; + font-size: 1.08rem; } } @@ -907,6 +1496,12 @@ max-width: 720px; } + .cyber-hero__scene { + width: 100%; + max-width: 960px; + margin: 24px auto 0; + } + .cyber-scene { min-height: 620px; max-width: 960px; @@ -917,12 +1512,13 @@ left: calc(var(--agent-tablet-x) * 1%); top: calc(var(--agent-tablet-y) * 1%); transform: - translate3d(-50%, -50%, 0) - translate3d( - calc(var(--hero-pointer-x) * var(--agent-tablet-depth) * 14px), - calc(var(--hero-pointer-y) * var(--agent-tablet-depth) * 10px), - 0 - ) + translate3d(-50%, -100%, 0) + scale(var(--agent-tablet-scale)); + } + + .cyber-agent[data-agent="planner"] { + transform: + translate3d(-66%, -56%, 0) scale(var(--agent-tablet-scale)); } @@ -930,9 +1526,34 @@ display: none; } + .cyber-feature-rail-shell { + width: 100%; + } + .cyber-feature-rail { grid-template-columns: repeat(3, minmax(0, 1fr)); - width: 100%; + } + + .cyber-feature-rail__collaboration { + left: 50%; + bottom: calc(100% - 14px); + width: 132px; + } + + .cyber-feature-rail__reviewer { + --reviewer-robot-width: 78px; + + right: 56px; + gap: 10px; + } + + .cyber-feature-rail__reviewer-card { + width: 220px; + font-size: 0.62rem; + } + + .cyber-feature-rail__robot { + width: var(--reviewer-robot-width); } .cyber-feature-rail__item:nth-child(3) { @@ -950,16 +1571,33 @@ } .cyber-hero__background { - background-image: url("~/assets/images/hero/backgrounds/cyber-city-mobile-v1.webp"); background-position: center top; - opacity: 0.76; + opacity: 0.82; transform: scale(1.02); } + .cyber-hero__monterey { + opacity: 0.58; + } + + .cyber-hero__monterey-canvas { + display: none; + } + .cyber-hero__container { width: min(100% - 32px, 680px); } + .cyber-hero__layout { + min-width: 0; + overflow: hidden; + } + + .cyber-hero__copy { + width: 100%; + max-width: 100%; + } + .cyber-hero__brand-lockup { margin-bottom: 28px; } @@ -997,20 +1635,25 @@ } .cyber-hero__terminal-note { - display: grid; - gap: 8px; + display: inline-flex; font-size: 0.68rem; } .cyber-scene { min-height: auto; aspect-ratio: auto; - padding: 90px 0 12px; + padding: 92px 0 12px; transform: none; } + .cyber-hero__scene { + width: 100%; + max-width: 100%; + margin-top: 18px; + overflow: hidden; + } + .cyber-scene__floor, - .cyber-scene__connectors, .cyber-scene__foreground { display: none; } @@ -1062,6 +1705,14 @@ padding: 16px; } + .cyber-feature-rail__collaboration { + display: none; + } + + .cyber-feature-rail__reviewer { + display: none; + } + .cyber-feature-rail__item { grid-template-columns: 42px minmax(0, 1fr); padding: 12px 0; @@ -1084,6 +1735,7 @@ } .cyber-hero__background, + .cyber-hero__monterey, .cyber-scene, .cyber-scene__video, .cyber-agent { diff --git a/landing/components/common/AppLogo.vue b/landing/components/common/AppLogo.vue index 41df1147..cd59e367 100644 --- a/landing/components/common/AppLogo.vue +++ b/landing/components/common/AppLogo.vue @@ -3,7 +3,7 @@ const { baseURL } = useRuntimeConfig().app;