diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml index 1213761b085..1ab22dd1dc9 100644 --- a/.github/actions/setup-project-bun/action.yml +++ b/.github/actions/setup-project-bun/action.yml @@ -1,8 +1,20 @@ name: Setup project Bun description: >- - Install the Bun runtime for a job. Installs the version declared in - package.json (dependencies.bun), keeping the runtime SOT in one place so - version bumps only touch package.json and bun.lock. + Install the package.json Bun pin for a job's role: dependencies.bun for + runtime (default), or testRunnerBun for test-runner. + +# Runtime 1.4.2 fixes #6684's Windows Bun.fetch stream truncation. The test runner +# skipped 1.4.2: #4821 reverted #4064 after its multi-file --isolate preload segfault +# (#6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). It moved from 1.4.0 to +# 1.4.3 for oven-sh/bun#44581: 1.4.0's Bun.spawnSync could lose a child's exit after +# finalizers ran inside an earlier call and spin past its own timeout (#4956). +# Rejoin only after the upstream runner crash is fixed and a green lane=all +# dispatch proves the unified version; then delete testRunnerBun and this role input. +inputs: + role: + description: Bun role to install (runtime or test-runner). + required: false + default: runtime outputs: version: @@ -15,14 +27,28 @@ runs: - name: Resolve project Bun version id: resolve shell: bash + env: + BUN_ROLE: ${{ inputs.role }} run: | - version="$(node -p "require('./package.json').dependencies.bun")" - if [ -z "$version" ]; then - echo "::error::Could not resolve Bun version from package.json" - exit 1 - fi - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "Using Bun $version from package.json" + node <<'NODE' + const pkg = require('./package.json'); + const role = process.env.BUN_ROLE; + let key; + let version; + switch (role) { + case 'runtime': key = 'dependencies.bun'; version = pkg.dependencies?.bun; break; + case 'test-runner': key = 'testRunnerBun'; version = pkg.testRunnerBun; break; + default: + console.error(`::error::Unknown Bun role: ${role}`); + process.exit(1); + } + if (typeof version !== 'string' || version !== version.trim() || !/^\d+\.\d+\.\d+$/.test(version)) { + console.error(`::error::Missing or invalid package.json ${key} for Bun role ${role}`); + process.exit(1); + } + require('node:fs').appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`); + console.log(`Using Bun ${version} from package.json ${key} (${role})`); + NODE - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 diff --git a/.github/workflows/catalog-async-contracts.yml b/.github/workflows/catalog-async-contracts.yml new file mode 100644 index 00000000000..eb6f0335009 --- /dev/null +++ b/.github/workflows/catalog-async-contracts.yml @@ -0,0 +1,59 @@ +name: Catalog async contracts + +on: + push: + branches: [main, preview] + paths: + - src/** + - scripts/diagnostics/windows-version-control/** + - .github/actions/setup-project-bun/** + - .github/workflows/catalog-async-contracts.yml + - package.json + - bun.lock + pull_request: + paths: + - src/** + - scripts/diagnostics/windows-version-control/** + - .github/actions/setup-project-bun/** + - .github/workflows/catalog-async-contracts.yml + - package.json + - bun.lock + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: catalog-async-contracts-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + contracts: + name: Catalog async contracts (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # Pull requests run the ubuntu leg only; macos and windows run on push + # to main/preview and on manual dispatch. + os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest","macos-latest","windows-latest"]') }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + - uses: ./.github/actions/setup-project-bun + with: + role: runtime + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master snapshot; the action publishes no release tags, so the toolchain is pinned explicitly + with: + toolchain: 1.99.0 + components: rustfmt, clippy + - name: Install repository dependencies + run: bun install --frozen-lockfile + - name: Check formatting + run: cargo fmt --manifest-path scripts/diagnostics/windows-version-control/Cargo.toml --check + - name: Check Rust contracts + run: cargo clippy --locked --manifest-path scripts/diagnostics/windows-version-control/Cargo.toml --all-targets -- -D warnings + - name: Run Bun-module contracts through the portable Rust fixture + run: cargo test --locked --manifest-path scripts/diagnostics/windows-version-control/Cargo.toml --test async_contracts -- --test-threads=4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87eccc7cf3f..2df7ddab928 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,11 @@ +# ============================================================================ +# CI POLICY (maintainer decision — do not change in a contributor PR) +# * Pull requests run ONLY the fast Ubuntu subset. +# * Full cross-platform (macOS/Windows) CI runs ONLY via manual +# workflow_dispatch and on push to main/preview (release lines). +# * NEVER add a dev push trigger or pull_request runs for macOS/Windows jobs. +# PRs that change this policy will be closed. See AGENTS.md "CI policy". +# ============================================================================ name: Cross-platform CI on: @@ -21,14 +29,19 @@ on: # because this workflow is `pull_request` (not `pull_request_target`), # declares `contents: read`, and reads no secrets. # - # `push:` stays pinned to the release lines: it gates publication, and - # review is covered by the pull_request trigger above. dev is dropped: - # its integration evidence comes from the pull_request run into dev, and - # workflow_dispatch covers anything a maintainer wants proven by hand. - # main and preview MUST stay: release.yml requires a successful - # push-event Cross-platform CI run for the exact release SHA and states - # that a pull-request run does not qualify, so removing either branch - # breaks publication. + # + # Pull requests run only the fast Ubuntu subset. Every macOS and Windows + # job (and the macOS/Windows legs of the keyring, npm-global, setup-action + # and remote-helper matrices) is skipped on `pull_request` and runs after + # merge instead, via workflow_dispatch, the main/preview `push:` below and + # the nightly schedule. + # + # `push:` is pinned to the release lines. dev is deliberately absent: + # cross-platform evidence for dev is run by hand via workflow_dispatch. + # main and preview MUST stay: release.yml requires a successful push-event + # Cross-platform CI run for the exact release SHA and states that a + # pull-request run does not qualify, so removing either branch breaks + # publication. push: branches: [main, preview] paths: @@ -63,6 +76,20 @@ on: type: choice default: all options: [all, release-gates, macos-control] + # Nightly safety net. The Windows shards run on a pull request only when the + # changes job judges it Windows-sensitive, so anything that slips past that + # filter must still be caught within a day: a scheduled run requests the full + # lane=all matrix (nine Windows shards and the macOS control included). + # + # GitHub runs schedules from the DEFAULT branch, which is main, so this + # trigger activates only once this file reaches main by promotion, and the + # workflow definition is main's. Every test checkout reads + # needs.changes.outputs.checkout_ref, which the changes job resolves to ONE + # dev commit on a schedule, so the run tests dev and every job tests the same + # SHA. Its check runs attach to main's head commit; release.yml accepts only a + # push-event run, so a scheduled run can never qualify a release. + schedule: + - cron: "17 18 * * *" permissions: contents: read @@ -88,8 +115,12 @@ concurrency: # `github.run_id` is unique per run, so each dispatch is a group of one: it # cancels nothing and nothing cancels it, including a second dispatch of the # same ref. - group: cross-platform-ci-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} - cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + # + # A scheduled run is keyed the same way. Keyed on the ref it would share + # refs/heads/main with a promotion push and, with cancel-in-progress, cancel + # the very run release.yml waits for. + group: cross-platform-ci-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'schedule' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' }} jobs: # Which Windows runner this run is allowed to use. @@ -118,6 +149,8 @@ jobs: # # `push` on main/preview requires the push permission, and # `workflow_dispatch` requires write access, so both carry a trusted author. + # A schedule runs main's workflow against a dev commit that already merged + # through review, the same trust as a push. # A trusted author is not audited code: merging a contributor PR into `dev` # fires `push`, and its dependencies and postinstall hooks then run here. select-windows-runner: @@ -141,7 +174,7 @@ jobs: set -euo pipefail trusted=no case "$EVENT_NAME" in - push|workflow_dispatch) trusted=yes ;; + push|workflow_dispatch|schedule) trusted=yes ;; esac # Repository variable OCX_SELF_HOSTED_WINDOWS is an OPERATIONAL switch, @@ -188,15 +221,26 @@ jobs: # shape-checked by the same validation step as `native`. keyring_matrix: ${{ steps.matrices.outputs.keyring_matrix }} npm_global_matrix: ${{ steps.matrices.outputs.npm_global_matrix }} - gui: ${{ steps.filter.outputs.gui }} - packaging: ${{ steps.filter.outputs.packaging }} - docs: ${{ steps.filter.outputs.docs }} - structure: ${{ steps.filter.outputs.structure }} + # A scheduled run starts from main, so its path filter diffs main and would + # scope jobs by the wrong commit. The nightly safety net selects every scope + # instead, here and in the validation steps below, so it is the full matrix. + gui: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.gui }} + packaging: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.packaging }} + docs: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.docs }} + structure: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.structure }} # Narrow scopes for two paths the ci filter leaves out on purpose. Both are re-emitted by the # validation step below, so a malformed filter output fails this job instead of silently # skipping the check it selects. setup_action: ${{ steps.narrow.outputs.setup_action }} remote_helper: ${{ steps.narrow.outputs.remote_helper }} + # Whether this pull request needs the nine Windows shards: the windows path + # filter below OR an added src/tests line naming a Windows marker. Computed + # by scripts/ci/windows-sensitive-diff.sh, which validates its input and + # treats the diff as data. + windows: ${{ steps.windows.outputs.windows }} + # The commit every test job checks out. Empty (the event's own commit) + # except on a schedule, where it is the one dev SHA resolved below. + checkout_ref: ${{ steps.nightly.outputs.checkout_ref }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -205,6 +249,9 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + # The pull request merge commit plus its first parent, the base tip, + # so the Windows marker scan can diff exactly this pull request. + fetch-depth: 2 - name: Detect changed areas id: filter @@ -341,6 +388,32 @@ jobs: # real only on macOS and Windows, so its job lints and tests the crate on all three. remote_helper: - 'native/remote-workspace-helper/**' + # Paths whose change selects the nine Windows suite shards on a pull + # request. These carry the Windows service, update, shim, intercept, + # ACL and process code, and the harness every Windows shard runs. + # scripts/ci/windows-sensitive-diff.sh adds the content markers. + windows: + - 'src/lib/windows-*' + - 'src/lib/*acl*' + - 'src/service/**' + - 'src/update/**' + - 'src/claude/intercept/**' + - 'src/codex/shim*' + - 'src/codex/app-server-processes.ts' + - 'src/codex/desktop-app/**' + - 'src/config/process-state.ts' + - 'src/cli/update-restart*' + - 'src/server/local-management-read-client.ts' + - 'src/server/startup-health-cache.ts' + - 'src/tray/**' + - 'scripts/test.ts' + - 'scripts/test-*.ts' + - 'scripts/ci/**' + - 'tests/preload.ts' + - 'tests/helpers/**' + - '.github/workflows/ci.yml' + - 'package.json' + - 'bun.lock' # Everything that ends up inside `npm pack`, or that decides what # does. `src/**` belongs here because package.json ships `src` and # bin/ocx.mjs executes it: without that entry an ordinary source PR @@ -366,8 +439,8 @@ jobs: id: scope shell: bash env: - CI_SCOPE: ${{ steps.filter.outputs.ci }} - DESKTOP_SCOPE: ${{ steps.filter.outputs.desktop }} + CI_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.ci }} + DESKTOP_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.desktop }} run: | set -euo pipefail case "$CI_SCOPE" in @@ -393,7 +466,8 @@ jobs: id: matrices shell: bash env: - NATIVE_SELECTED: ${{ steps.filter.outputs.native }} + NATIVE_SELECTED: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.native }} + EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail case "$NATIVE_SELECTED" in @@ -406,22 +480,30 @@ jobs: ;; esac - # Two unconditional legs plus the macos leg that rides the native - # selection. These strings are what the keyring-smoke and - # npm-global-smoke matrices consume through fromJSON. - if [ "$NATIVE_SELECTED" = "true" ]; then + # These strings are what the keyring-smoke and npm-global-smoke + # matrices consume through fromJSON. A pull request runs only the + # ubuntu leg; the windows and macos legs run after merge on push + # (and on dispatch and the schedule). Outside pull requests, ubuntu + # and windows always run and macos rides the native selection. + if [ "$EVENT_NAME" = "pull_request" ]; then + keyring_matrix='[{"name":"ubuntu","runner":"ubuntu-latest"}]' + npm_global_matrix='[{"os":"ubuntu-latest"}]' + min_legs_filter='length >= 1' + elif [ "$NATIVE_SELECTED" = "true" ]; then keyring_matrix='[{"name":"ubuntu","runner":"ubuntu-latest"},{"name":"windows","runner":"windows-latest"},{"name":"macos","runner":"macos-latest"}]' npm_global_matrix='[{"os":"ubuntu-latest"},{"os":"windows-latest"},{"os":"macos-latest"}]' + min_legs_filter='length >= 2' else keyring_matrix='[{"name":"ubuntu","runner":"ubuntu-latest"},{"name":"windows","runner":"windows-latest"}]' npm_global_matrix='[{"os":"ubuntu-latest"},{"os":"windows-latest"}]' + min_legs_filter='length >= 2' fi # GitHub turns an empty matrix include list into a job with no legs # that still reports success, so the emitted value itself is - # validated: it must parse as a JSON array carrying at least the two - # unconditional legs. A malformed or empty matrix fails this job - # instead of passing over nothing. + # validated: it must parse as a JSON array carrying the ubuntu leg, + # and outside pull requests the windows leg too. A malformed or + # empty matrix fails this job instead of passing over nothing. assert_matrix() { local label="$1" json="$2" jq_filter="$3" if ! printf '%s' "$json" | jq -e "$jq_filter" >/dev/null; then @@ -429,10 +511,17 @@ jobs: exit 1 fi } + if [ "$EVENT_NAME" = "pull_request" ]; then + keyring_windows='true' + npm_global_windows='true' + else + keyring_windows='any(.[]; .name == "windows")' + npm_global_windows='any(.[]; .os == "windows-latest")' + fi assert_matrix keyring "$keyring_matrix" \ - 'type == "array" and length >= 2 and any(.[]; .name == "ubuntu") and any(.[]; .name == "windows")' + "type == \"array\" and $min_legs_filter and any(.[]; .name == \"ubuntu\") and $keyring_windows" assert_matrix npm-global "$npm_global_matrix" \ - 'type == "array" and length >= 2 and any(.[]; .os == "ubuntu-latest") and any(.[]; .os == "windows-latest")' + "type == \"array\" and $min_legs_filter and any(.[]; .os == \"ubuntu-latest\") and $npm_global_windows" printf 'keyring_matrix=%s\n' "$keyring_matrix" >> "$GITHUB_OUTPUT" printf 'npm_global_matrix=%s\n' "$npm_global_matrix" >> "$GITHUB_OUTPUT" @@ -441,8 +530,8 @@ jobs: id: narrow shell: bash env: - SETUP_ACTION: ${{ steps.filter.outputs.setup_action }} - REMOTE_HELPER: ${{ steps.filter.outputs.remote_helper }} + SETUP_ACTION: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.setup_action }} + REMOTE_HELPER: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.remote_helper }} run: | set -euo pipefail for pair in "setup_action=$SETUP_ACTION" "remote_helper=$REMOTE_HELPER"; do @@ -457,6 +546,44 @@ jobs: esac done + - name: Decide whether the Windows shards run + id: windows + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + PATH_SELECTED: ${{ steps.filter.outputs.windows }} + run: bash scripts/ci/windows-sensitive-diff.sh + + # A scheduled run starts from main's head. Resolve dev ONCE so the nine + # Windows shards, the macOS control and every other job test one commit, + # however long the queue between them. contents: read already covers it. + - name: Resolve the commit a scheduled run tests + id: nightly + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "schedule" ]; then + echo 'checkout_ref=' >> "$GITHUB_OUTPUT" + exit 0 + fi + sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/dev" --jq .sha)" + if ! printf '%s' "$sha" | grep -Eqx '[0-9a-f]{40}'; then + printf '::error::could not resolve dev to a commit SHA\n' + exit 1 + fi + printf 'checkout_ref=%s\n' "$sha" >> "$GITHUB_OUTPUT" + printf 'Scheduled run: workflow from %s, testing dev at %s\n' "$GITHUB_SHA" "$sha" >> "$GITHUB_STEP_SUMMARY" + printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA" + + # Bun roles (#6713): runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; + # the test runner skipped 1.4.2's multi-file --isolate preload segfault (#4821 + # reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and runs + # 1.4.3, whose oven-sh/bun#44581 stops Bun.spawnSync losing a child's exit (#4956). + # Rejoin after the upstream runner crash is fixed and a green lane=all dispatch + # proves the unified version, then delete testRunnerBun and the action's role input. # The suite, split by file across four Linux runners. # # `scripts/ci/run-bun-test-batches.sh` assigns files to shards by the per-file @@ -496,6 +623,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # tests/ci-workflows/release-version-line.test.ts compares package.json against the # newest release tag. actions/checkout fetches no tags by default, so # without this the check reads an empty tag set and passes on anything - @@ -510,6 +638,8 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. # The GUI install is NOT optional here, however unrelated it looks to a # test shard. Several files under tests/ import JSX-bearing modules from @@ -552,9 +682,12 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. - name: Install dependencies run: | @@ -591,9 +724,12 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. - name: Install dependencies run: | @@ -626,9 +762,12 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. - name: Install dependencies run: | @@ -688,23 +827,29 @@ jobs: run: bun run src/cli/index.ts help platform-macos: - name: macos ${{ matrix.shard }}/2 + name: macos ${{ matrix.shard }}/3 needs: changes - # Native-gated: these legs only run when the changes job's `native` filter - # says the macOS suite's inputs could have changed, so an ordinary source - # pull request stops paying for two macOS runners. - if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.native == 'true') + # Never on pull requests: macOS legs run after merge, on push to + # main/preview, and on dispatch and the schedule. + if: github.event_name != 'pull_request' runs-on: macos-latest - # Two shards. Unsharded, this job was the critical path on every green dev + # Three shards. Unsharded, this job was the critical path on every green dev # push (mean 14.9 min against a 4.7 min Linux maximum; devlog - # 260905_test_modularization_and_windows/003). Two halves finish in ~7.7 and - # cost 0.6 extra macOS minutes of setup per run. The full-membership control - # runs the same bounded batches without sharding on explicit dispatch. + # 260905_test_modularization_and_windows/003), so it was split in two. The + # suite then outgrew two halves: across seventeen dev dispatches from + # 2026-10-04 to 10-12 the slower half's test step went from 867s to 1155s, + # and run 38214884055 cancelled macos 2/2 at this cap with 68 files left + # while every test it had run was passing (#6978). Replaying the duration + # assignment against those runs' measured per-file times puts the slowest of + # three shards at 635-796s of tests, about 14 minutes with setup. The cap + # stays 20: the suite needs another shard, not a longer wait, and a wedged + # shard (#4956) is still cut off as early as before. The full-membership + # control runs the same bounded batches without sharding on explicit dispatch. timeout-minutes: 20 strategy: fail-fast: false matrix: - shard: [1, 2] + shard: [1, 2, 3] steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -713,6 +858,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # tests/ci-workflows/release-version-line.test.ts compares package.json against the # newest release tag. actions/checkout fetches no tags by default, so # without this the check reads an empty tag set and passes on anything - @@ -727,6 +873,8 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. - name: Install dependencies run: | @@ -747,7 +895,7 @@ jobs: - name: Test in fresh-process batches env: - TEST_SHARD: ${{ matrix.shard }}/2 + TEST_SHARD: ${{ matrix.shard }}/3 BUN_TEST_FILE_SCOPE: all BUN_TEST_BATCH_SIZE: '12' BUN_TEST_PARALLEL: '1' @@ -764,8 +912,9 @@ jobs: needs: changes # Diagnostic-only lanes stay off the ordinary release gates: list every lane # that should run this job, so a new lane defaults to skipping the diagnostic. + # The nightly schedule is the full lane=all matrix, so it runs this job too. if: >- - github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control') + github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control') runs-on: macos-latest # Unsharded full-membership control, one worker in sequential fresh batches. # Long-lived Bun isolate pools repeatedly wedged while synchronously reaping @@ -780,6 +929,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # tests/ci-workflows/release-version-line.test.ts compares package.json against the # newest release tag. actions/checkout fetches no tags by default, so # without this the check reads an empty tag set and passes on anything - @@ -794,6 +944,8 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. - name: Install dependencies run: | @@ -826,7 +978,9 @@ jobs: - name: CLI help smoke run: bun run src/cli/index.ts help - # Windows runs only when a maintainer asks for it by hand. + # Windows runs on a Windows-sensitive pull request, on the nightly schedule, + # and on a lane=all (or default) dispatch. It is never part of the push run + # release.yml gates on. # # It left the PR lane first (16m23s against a 6-minute Linux critical path; # the ceiling was raised twice rather than the gap closed — #711 vs #653, @@ -842,11 +996,18 @@ jobs: # release.yml gates on a successful push-event run of this workflow, which # now means Linux + macOS + the gates; Windows re-enters the gate when the # tracked failures are fixed, not before. + # + # Dispatch-only proved too late. #6639, #6556, #6605 and #6606 each landed with + # green pull-request CI while breaking Windows, and the breakage surfaced days + # later on a manual dispatch. So a pull request whose changes job reports + # windows=true (a Windows path, or an added src/tests line naming a Windows + # marker; see scripts/ci/windows-sensitive-diff.sh) now runs the nine shards, + # and the daily schedule catches whatever that filter misses. platform-windows: name: windows ${{ matrix.shard }}/9 - needs: select-windows-runner + needs: [changes, select-windows-runner] if: >- - github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all') + github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all') runs-on: ${{ fromJSON(needs.select-windows-runner.outputs.runner) }} # Sharded like the Linux legs. The single-leg run reached 30 minutes on a # green suite and was killed in cleanup; four shards put each leg inside the @@ -919,6 +1080,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # Same reason as the Linux shards and the macOS control: this leg runs the # whole suite, and tests/ci-workflows/release-version-line.test.ts reads release tags. # Without tags the check sees an empty set and cannot fail. @@ -926,6 +1088,8 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner # See the Bun roles contract above test. - name: Install dependencies run: | @@ -998,6 +1162,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1051,6 +1216,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1080,6 +1246,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1116,6 +1283,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1127,8 +1295,8 @@ jobs: run: bun run structure:check # The composite Bun setup, run on each runner family it serves, when a change touches only the - # action (see the setup_action filter). One step past the action proves it installed the runtime - # package.json declares; nothing else runs, so this never grows into a suite. + # action (see the setup_action filter). Each role proves its resolved and installed + # version matches package.json; nothing else runs, so this never grows into a suite. setup-action: name: setup action ${{ matrix.os }} needs: changes @@ -1138,12 +1306,14 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + # Pull requests run the ubuntu leg only; windows and macos run after merge. + os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest","windows-latest","macos-latest"]') }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun id: bun @@ -1163,6 +1333,26 @@ jobs: exit 1 fi + - name: Setup project Bun test runner + id: bun-test-runner + uses: ./.github/actions/setup-project-bun + with: + role: test-runner + + - name: Require the test runner package.json declares + shell: bash + env: + RESOLVED: ${{ steps.bun-test-runner.outputs.version }} + run: | + set -euo pipefail + declared="$(node -p "require('./package.json').testRunnerBun")" + installed="$(bun --version)" + echo "declared=$declared resolved=$RESOLVED installed=$installed" + if [ "$RESOLVED" != "$declared" ] || [ "$installed" != "$declared" ]; then + echo "::error::setup-project-bun resolved '$RESOLVED' and installed '$installed', but package.json testRunnerBun declares '$declared'" + exit 1 + fi + # The Rust remote-workspace helper, when a change touches it (see the remote_helper filter). # Formatting once, then clippy and the crate's tests on each platform: the sandbox and the live # confinement tests compile only on macOS and Windows, and Linux covers the protocol and stub. @@ -1175,12 +1365,14 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + # Pull requests run the ubuntu leg only; macos and windows run after merge. + os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest","macos-latest","windows-latest"]') }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup Rust uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master @@ -1214,6 +1406,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1257,6 +1450,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # Deliberately NO setup-bun: prove `npm install -g` works without a # separately-installed Bun. The launcher uses the bundled `bun` dependency. @@ -1303,9 +1497,9 @@ jobs: widget: name: macos widget + bundle needs: [changes, gates] - # Native-gated like platform-macos: the widget extension and app bundle - # are rebuilt only when native-capable paths changed. - if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.native == 'true') + # Never on pull requests, like platform-macos: the widget extension and + # app bundle are rebuilt after merge, on push, dispatch and the schedule. + if: github.event_name != 'pull_request' runs-on: macos-latest timeout-minutes: 30 steps: @@ -1313,11 +1507,10 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun - name: Install dependencies run: | @@ -1390,6 +1583,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Install Tauri Linux dependencies run: | @@ -1534,6 +1728,7 @@ jobs: CHANGES_REMOTE_HELPER: ${{ needs.changes.outputs.remote_helper }} CHANGES_NATIVE: ${{ needs.changes.outputs.native }} CHANGES_DESKTOP: ${{ needs.changes.outputs.desktop }} + CHANGES_WINDOWS: ${{ needs.changes.outputs.windows }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -1554,13 +1749,13 @@ jobs: if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_CI" != "true" ]; then scoped=not-requested fi - # platform-macos and widget carry the ordinary scope gate AND the native path filter. - # desktop-shell accepts that native set plus the package-E2E set. - # This mirrors that expression exactly; where it disagrees with the - # jobs' own `if:`, the gate fails by name instead of demanding + # platform-macos and widget never run on a pull request (macOS runs after merge). + # desktop-shell (ubuntu) carries the ordinary scope gate AND the native or + # package-E2E path filters. This mirrors those expressions exactly; where it + # disagrees with the jobs' own `if:`, the gate fails by name instead of demanding # success from a job that was deliberately left unselected. native=not-requested - if [ "$EVENT_NAME" != "pull_request" ] || { [ "$CHANGES_CI" = "true" ] && [ "$CHANGES_NATIVE" = "true" ]; }; then + if [ "$EVENT_NAME" != "pull_request" ]; then native=requested fi desktop_shell=not-requested @@ -1597,7 +1792,11 @@ jobs: fi dispatch=not-requested windows=not-requested - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "$EVENT_NAME" = "schedule" ]; then + # The nightly safety net is the full lane=all matrix. + dispatch=requested + windows=requested + elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then # Mirror the diagnostic job allowlist; release-gates requests neither suite. if [ -z "$LANE" ] || [ "$LANE" = "all" ] || [ "$LANE" = "macos-control" ]; then dispatch=requested diff --git a/.github/workflows/codex-queue-helpers.yml b/.github/workflows/codex-queue-helpers.yml index 380d27ba93d..62b5991b793 100644 --- a/.github/workflows/codex-queue-helpers.yml +++ b/.github/workflows/codex-queue-helpers.yml @@ -8,13 +8,15 @@ on: - 'docs-site/src/content/docs/guides/composer-usage-gate-fallback.md' - '.github/workflows/codex-queue-helpers.yml' push: - branches: [dev, main, preview] + branches: [main, preview] paths: - 'scripts/codex-queue.*' - 'docs-site/astro.config.mjs' - 'docs-site/src/content/docs/guides/composer-usage-gate-fallback.md' - '.github/workflows/codex-queue-helpers.yml' + workflow_dispatch: + permissions: contents: read diff --git a/.github/workflows/dev-version-bump.yml b/.github/workflows/dev-version-bump.yml index b54a6d76d31..99a8946c3e4 100644 --- a/.github/workflows/dev-version-bump.yml +++ b/.github/workflows/dev-version-bump.yml @@ -78,10 +78,13 @@ jobs: persist-credentials: false # The repository-owned composite action, not a hand-pinned setup-bun SHA: it - # resolves the Bun version from package.json so the runtime SOT stays in one - # place. An independently pinned action here would drift from every other job. + # selects package.json's test-runner pin even for this single-file test so + # validation follows the CI runner contract (see setup-project-bun/action.yml). + # This job edits version metadata; it does not compile a shipped runtime. - name: Setup project Bun uses: ./.github/actions/setup-project-bun + with: + role: test-runner - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/service-lifecycle.yml b/.github/workflows/service-lifecycle.yml index e2e89670ba1..ad865033406 100644 --- a/.github/workflows/service-lifecycle.yml +++ b/.github/workflows/service-lifecycle.yml @@ -24,11 +24,11 @@ on: # produced no run and the gate dead-ended until a manual dispatch. - ".github/workflows/release.yml" push: - # Release lines only. release.yml needs a successful push run for the exact - # release SHA, and scripts/release.ts only releases from main or preview. - # Without a branch filter every feature-branch push whose range carried a - # dev merge touching these paths re-ran the macOS and Windows legs that the - # pull_request trigger above already runs for the same change. + # Release lines only (no dev: cross-platform legs run on dev by manual + # workflow_dispatch). Pull requests run only the Linux leg. main and preview + # must stay: release.yml needs a successful push run for the exact release + # SHA, and scripts/release.ts only releases from main or preview. Without a + # branch filter every feature-branch push would re-run all three legs. branches: [main, preview] paths: - "src/service.ts" @@ -177,6 +177,8 @@ jobs: fi macos-launchd: + # Pull requests run the Linux leg only; macOS runs on manual dispatch and main/preview push. + if: github.event_name != 'pull_request' runs-on: macos-latest timeout-minutes: 10 steps: @@ -255,6 +257,8 @@ jobs: fi windows-schtasks: + # Pull requests run the Linux leg only; Windows runs on manual dispatch and main/preview push. + if: github.event_name != 'pull_request' runs-on: windows-latest timeout-minutes: 10 steps: diff --git a/AGENTS.md b/AGENTS.md index bf4048b86d5..2ba03009389 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Bun-native TypeScript with no separate server compile step. - `tests/` — Bun tests in domain directories that mirror `src/` (`tests//*.test.ts`; `providers/` and `adapters/` have one more level for the larger vendors). The explicit map is - `scripts/test-layout/layout.json`, with regex seeds and migration state in + `scripts/test-layout/layout.json` plus its `explicitShards` sibling maps, with regex seeds and migration state in `scripts/test-layout/seeds.json`; `tests/test-layout.test.ts` enforces that every file resolves to a domain and sits in it, and only the two layout guards live at the root. Shared helpers in `tests/helpers/`, fixtures in `tests/fixtures/`, broader @@ -23,7 +23,7 @@ Bun-native TypeScript with no separate server compile step. through `tests/helpers/repo-root.ts` (`repoRoot()`, `repoPath()`, `helperPath()`, `fixturePath()`), never `import.meta.dir + "/.."`. A new test file lands in its domain directory and needs an entry in both - `layout.json` `explicit` and `tests/fixtures/test-layout-expected.json` + `layout.json` `explicit` (or its domain shard) and a `tests/fixtures/test-layout-expected*.json` shard (`tests/test-layout-tooling.test.ts` names the missing one); the regex seeds in `seeds.json` place a conventionally named file until then. History: `devlog/_fin/260905_test_modularization_and_windows/`. @@ -268,7 +268,7 @@ fails `file-size ratchet: repository` for your branch and for every branch cut f Two branches can each stay under a cap alone and sum over it together; that is what happened in #4908, #5011 and #5018. The remedy is always a move, never a number: put the new case in a sibling file, byte for byte, and register it in **both** -`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. +`scripts/test-layout/layout.json` (or its domain shard) and a `tests/fixtures/test-layout-expected*.json` shard. `d3ca5522db` is the original precedent. A moved test is not automatically the same test. One case moved out of @@ -365,6 +365,14 @@ than nudged. recorded in [`CREDITS.md`](./CREDITS.md); `missing_coauthor_credit` in `.github/scripts/pr-carry-attribution.cjs` is why the list should not grow. +## CI policy (maintainer decision — do not change) + +- Pull requests run **only the fast Ubuntu subset**. +- Full cross-platform (macOS/Windows) CI runs **only** via manual + `workflow_dispatch` and on push to `main`/`preview` (release lines). +- **Never** add a `dev` push trigger, or `pull_request` runs for macOS/Windows + jobs, to any workflow. PRs that change this policy will be closed. + ## Branch policy - `dev` — the single integration branch and the target for every pull request. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e28dac23c2..9ae2a4555e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,8 +33,16 @@ For local development commands, architecture notes, and release workflow details contributing guide above instead of duplicating instructions here. Source development requires the `bun` CLI on your `PATH`. The published npm package bundles its own -Bun runtime for end users, but contributor commands such as `bun install`, `bun run test`, and -`bun run prepush` run from your local Bun installation. +Bun runtime for end users. Package scripts such as `bun run test` and `bun run prepush` may resolve +Bun through that bundled dependency. + +## CI policy (maintainer decision — do not change) + +- Pull requests run **only the fast Ubuntu subset**. +- Full cross-platform (macOS/Windows) CI runs **only** via manual + `workflow_dispatch` and on push to `main`/`preview` (release lines). +- **Never** add a `dev` push trigger, or `pull_request` runs for macOS/Windows + jobs, to any workflow. PRs that change this policy will be closed. ## Pull request contract @@ -61,6 +69,10 @@ A ready-for-review PR is the author's claim that the change is complete, underst ## Local validation and hooks +The suite uses Bun 1.4.3 (`testRunnerBun`) while the shipped runtime is 1.4.2. +`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses +`OCX_TEST_RUNNER_BUN`; install that version locally if it is missing. + Run `bun run test` before review readiness. If the full local suite is too costly for the task or available resources, run at least focused regression tests for the changed behavior. Document the reason, commands, results, and remaining diff --git a/Dockerfile b/Dockerfile index f8cfc4002b8..7e36f2063a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,13 @@ # syntax=docker/dockerfile:1 # Keep the runtime aligned with package.json and pin the multi-platform image index. -ARG BUN_IMAGE=oven/bun:1.4.0@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6 +# Runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; CI tests run on 1.4.3 +# because 1.4.2 segfaults reloading preloads in multi-file --isolate (#4821; +# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and 1.4.0's spawnSync +# can lose a child's exit (#4956, oven-sh/bun#44581). Rejoin after the +# upstream crash fix passes a unified-version lane=all dispatch, then delete +# package.json's testRunnerBun pin and setup-project-bun's role input. +ARG BUN_IMAGE=oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 FROM ${BUN_IMAGE} AS manifest WORKDIR /home/bun/app diff --git a/README.md b/README.md index ec815156577..e8223c7eac8 100644 --- a/README.md +++ b/README.md @@ -154,10 +154,6 @@ See [SPONSORS.md](./SPONSORS.md). Thanks to OrcaRouter for sponsoring this project! OrcaRouter is one OpenAI-compatible AI gateway for production AI: adaptive routing that grades every prompt and sends it to the model that clears your bar, automatic failover, routing rules as code, zero-markup provider pricing with prompt caching, and guardrails, an agent firewall, and request logs on every call across 200+ models. Pick OrcaRouter in the Add provider picker or run ocx provider add orcarouter; orcarouter/auto is the adaptive router. -PackyCode -Thanks to PackyCode for sponsoring this project! PackyCode is a stable, high-performance API relay provider, offering relay services for Claude Code, Codex, Gemini, and more. With automatic failover, smart routing, and unlimited concurrency, it turns AI into a real productivity tool. Register via this link and get started! Pick PackyCode in the Add provider picker or run ocx provider add packycode.
PackyCode 是一家稳定、高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。具备自动故障转移、智能路由和无限并发等多种功能,让 AI 编程成为真正的生产力工具。点此链接注册,立即开始使用! - - TokenLab Thanks to TokenLab for sponsoring this project! TokenLab gives coding agents one API key for leading models, supporting OpenAI Responses and Chat Completions, Anthropic Messages, and Gemini's native API formats, with streaming and tool calling. It also provides an MCP server and agent Skills for easy integration. Choose your delivery mode and pay as you go. Pick TokenLab in the Add provider picker or run ocx provider add tokenlab.
TokenLab 为编程智能体提供统一的多模型 API,一枚 API Key 即可接入主流模型,支持 OpenAI Responses、Chat Completions、Anthropic Messages 和 Gemini 原生 API 格式,以及流式输出和工具调用。同时提供 MCP 服务器和 Agent Skills,方便接入现有工作流;交付模式可选,按量付费。 @@ -320,7 +316,7 @@ OpenCodex tracks process-retained state in the categories below. Each has a docu store, which is pinned and never evicted. - **4 observed buffers** (translator accumulators, image/OAuth/Grok tails) are monitored for in-flight byte pressure without eviction. -- **28 state-store registrations** handle expiry sweeps (60 s interval) and +- **31 state-store registrations** handle expiry sweeps (60 s interval) and config-generation reconciliation so stale provider/account keys are removed. - **Path and fingerprint memos** (workspace metadata, hardened identities, installation salts, mode-hint capabilities) use insertion-order LRU caps (8–128 entries). @@ -458,8 +454,8 @@ Acknowledging a report is not the same as triaging it, and no first-response tar ## Development -Source development requires the `bun` CLI on your `PATH`. This is separate from the published npm -package's bundled Bun runtime, which is used only by installed `ocx` commands. +Source development requires the `bun` CLI on your `PATH`. The published npm package bundles its own +Bun runtime for installed `ocx` commands; package scripts may also resolve Bun through that bundled dependency. ```bash git clone https://github.com/lidge-jun/opencodex.git diff --git a/assets/sponsors/packycode-overview-mobile.png b/assets/sponsors/packycode-overview-mobile.png deleted file mode 100644 index 934fa6341ff..00000000000 Binary files a/assets/sponsors/packycode-overview-mobile.png and /dev/null differ diff --git a/assets/sponsors/packycode-overview.png b/assets/sponsors/packycode-overview.png deleted file mode 100644 index ca86f481f34..00000000000 Binary files a/assets/sponsors/packycode-overview.png and /dev/null differ diff --git a/assets/sponsors/packycode-picker.png b/assets/sponsors/packycode-picker.png deleted file mode 100644 index 0f61d1fa867..00000000000 Binary files a/assets/sponsors/packycode-picker.png and /dev/null differ diff --git a/assets/sponsors/packycode-readme.png b/assets/sponsors/packycode-readme.png deleted file mode 100644 index 36e9f8a667e..00000000000 Binary files a/assets/sponsors/packycode-readme.png and /dev/null differ diff --git a/assets/sponsors/packycode.png b/assets/sponsors/packycode.png deleted file mode 100644 index 8b2afee75fd..00000000000 Binary files a/assets/sponsors/packycode.png and /dev/null differ diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 4cd3380ae18..6baf5168b5f 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -9,6 +9,9 @@ * src/cli/index.ts — only the published npm/pnpm `bin` routes through here.) */ import { spawn, spawnSync } from "node:child_process"; +import { createSupervisionLatch, inspectDesktopSupervision } from "../src/service/desktop-supervision.mjs"; +import { planDesktopCliHandoff, runDesktopCliHandoff } from "../src/lib/desktop-cli-handoff.mjs"; +import { desktopCliRecordPath } from "../src/lib/desktop-cli-record.mjs"; import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs"; import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; @@ -34,6 +37,7 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; +import { findDesktopCli, findPathBun } from "../src/lib/bun-path-runtime.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs"; import { detectInstallOwnershipFromPath } from "../src/update/install-detection.mjs"; @@ -57,6 +61,10 @@ import { CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS, isCodexCliUpdateInspectionArgv, } from "../src/update/codex-cli-update-launch-policy.mjs"; +import { + freshServingPid, hasRestartableServiceManager, readParentPid, readServiceManagerPid, restartServiceManagerJob, + supervisionVerdict, unsettledManagerWarning, unsupervisedRuntimeWarning, +} from "../src/update/supervised-recovery.mjs"; const PKG = "@bitkyc08/opencodex"; const UPDATE_RECOVERY_READY_MS = 30_000; @@ -95,6 +103,15 @@ function currentPackageVersion() { } } +function pinnedBunVersion() { + try { + const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")); + return typeof pkg.dependencies?.bun === "string" ? pkg.dependencies.bun : ""; + } catch { + return ""; + } +} + function updateTag(currentVersion) { // Allowlist the tag: the value is argv-controlled and (on Windows) flows into a // shell-joined spawnSync — never forward arbitrary strings. @@ -323,11 +340,13 @@ function runPackageManagerSelfUpdate(manager) { : JSON.stringify(observation.ownership ? ["owned", observation.ownership.owner, observation.ownership.installId, observation.ownership.consentGeneration] : ["none"]); + const supervisionLatch = createSupervisionLatch(); + const observeSupervision = () => supervisionLatch.observe(inspectDesktopSupervision()); const initialOwnership = readOwnership(); - let runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled }); + let runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); if (runtimePlan.notice) console.log(runtimePlan.notice); if (!runtimePlan.mayReplacePackage) { - console.error("opencodex: update stopped before tray handoff, runtime stop, or package replacement because runtime ownership is unknown."); + console.error("opencodex: update stopped before tray handoff, runtime stop, or package replacement because runtime authority does not permit it."); process.exit(1); } const trayBeforeUpdate = planWindowsTrayUpdate( @@ -455,7 +474,16 @@ function runPackageManagerSelfUpdate(manager) { ? ownershipMutationLeaseChildEnvironment(process.env, delegatedOwnershipMutationToken) : unprivilegedOwnershipMutationEnvironment(process.env); + /** Returns true, false, or "vetoed" when another owner holds the runtime (not a failure). */ function startProxyDirectly() { + const supervision = observeSupervision(); + const plan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: false, supervision }); + if (!plan.mayStopRuntime) { + console.warn(supervision + ? "OpenCodex Desktop supervises the proxy; no CLI runtime was restored." + : plan.notice); + return "vetoed"; + } if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) { console.error("opencodex: cannot restart the proxy because the launcher is missing; reinstall opencodex manually."); return false; @@ -487,7 +515,60 @@ function runPackageManagerSelfUpdate(manager) { return false; } + // #7043: before any detached start, ask launchd/systemd to restart the job it already holds, + // and count it only when the serving proxy is that job (or its launcher's child). Returns + // "supervised", "failed", or "unsettled" when the manager may still finish the restart. + function restartThroughServiceManager() { + if (!hasRestartableServiceManager()) return "failed"; + console.log("opencodex: asking the service manager to restart the background service."); + const before = readCurrentRuntimeTarget(); + const staleRecordPid = before.kind === "target" ? before.target.pid : null; + const restarted = restartServiceManagerJob(); + const unsettled = !restarted.ok && restarted.unsettled === true; + let verdict; + if (!restarted.ok && !unsettled) { + verdict = { kind: "failed", reason: restarted.reason }; + } else { + let servingPid = null; + const deadline = Date.now() + UPDATE_RECOVERY_READY_MS; + while (servingPid === null && Date.now() < deadline) { + const current = readCurrentRuntimeTarget(); + if (current.kind === "target" && current.target.port === bakePort + && freshServingPid(current.target.pid, staleRecordPid) !== null + && probeProxyLiveness(current.target.port, current.target.hostname ?? bakeHostname) === "live") { + servingPid = current.target.pid; + } else { + Atomics.wait(UPDATE_RECOVERY_SLEEP, 0, 0, UPDATE_RECOVERY_POLL_MS); + } + } + verdict = supervisionVerdict({ servingPid, managerPid: readServiceManagerPid(), parentPid: pid => readParentPid(pid) }); + } + if (verdict.kind === "supervised") { + console.log(`opencodex: the proxy is healthy on port ${bakePort} under the service manager (pid ${verdict.managerPid}).`); + return "supervised"; + } + if (unsettled) { + console.warn(unsettledManagerWarning(bakePort)); + return "unsettled"; + } + console.warn(`opencodex: the service manager did not recover a supervised proxy: ${verdict.reason}.`); + return "failed"; + } + + /** + * Returns "unsupervised" when only a detached fallback was attempted, or "unsettled" when the + * manager may still finish its restart; callers exit non-zero for both. + */ function refreshBackgroundServiceOrStartDirect() { + const mayRefresh = () => { + const supervision = observeSupervision(); + const plan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true, supervision }); + if (!plan.mayRestoreService) console.warn(supervision + ? "OpenCodex Desktop supervises the proxy; CLI recovery was skipped. Use the app's updater (tray → Check for Updates)." + : plan.notice); + return plan.mayRestoreService; + }; + if (!mayRefresh()) return; const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(bakePort); try { @@ -503,6 +584,7 @@ function runPackageManagerSelfUpdate(manager) { // failure would resurrect the elevation prompt this change exists to avoid, and // could re-register a service the user just uninstalled. if (svc.status !== 0 && readServiceInstalledFromStatus(postUpdateLauncher) === false) { + if (!mayRefresh()) return; console.log("No registered service found — installing it instead."); svc = spawnSync(process.execPath, serviceInstallArgs(), { stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), @@ -535,11 +617,18 @@ function runPackageManagerSelfUpdate(manager) { // Re-read rather than reuse the plan from before the package install: the app can // claim the runtime during an update that takes minutes, and the refusal that repair // just returned is indistinguishable from any other failure at this layer. - const nowOwned = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true }); + const supervision = observeSupervision(); + const nowOwned = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true, supervision }); if (!nowOwned.mayStopRuntime) { - console.warn(nowOwned.notice ?? "opencodex: the background runtime is owned elsewhere; not starting a second proxy."); + console.warn(supervision + ? "OpenCodex Desktop supervises the proxy; CLI recovery was skipped. Use the app's updater (tray → Check for Updates)." + : nowOwned.notice ?? "opencodex: the background runtime is owned elsewhere; not starting a second proxy."); return; } + // #7043: the manager first. Only a proxy it owns counts as recovered. + const managed = restartThroughServiceManager(); + if (managed === "supervised") return; + if (managed === "unsettled") return "unsettled"; // Repair normally avoids elevation for a healthy registration, but a stale Windows // scheduler definition can require it. It can also fail — or exit 0 while leaving // a non-viable manager. Fall back to a direct detached proxy start so the @@ -550,7 +639,11 @@ function runPackageManagerSelfUpdate(manager) { : "opencodex: service refresh failed — starting the proxy directly instead.", ); console.warn(" Run 'ocx service repair' to see why the background service could not restart."); - startProxyDirectly(); + const started = startProxyDirectly(); + // An ownership veto is the correct outcome, not a degraded one. + if (started === "vetoed") return; + if (started) console.warn(unsupervisedRuntimeWarning(bakePort)); + return "unsupervised"; } } finally { if (prevBake === undefined) delete process.env.OCX_BAKE_PORT; @@ -574,7 +667,7 @@ function runPackageManagerSelfUpdate(manager) { // Stop authority is decided under the same lease the child joins. A takeover between the // earlier preflight and this boundary therefore blocks stop before it is sent. const lockedOwnership = readOwnership(); - const lockedPlan = planUpdateRuntimeHandling({ ...lockedOwnership, serviceInstalled: serviceWasInstalled }); + const lockedPlan = planUpdateRuntimeHandling({ ...lockedOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); if (lockedOwnership.subjectToken !== initialOwnership.subjectToken || !lockedPlan.mayReplacePackage) { releaseUpdateLease(); console.error(lockedPlan.notice @@ -616,6 +709,7 @@ function runPackageManagerSelfUpdate(manager) { liveness, plan: planStoppedRuntimeRecovery({ stopAttempted, + supervision: observeSupervision(), ...recoveryOwnership, sameOwner: ownershipIdentity(recoveryOwnership) === stoppedOwnershipIdentity, liveness, @@ -633,7 +727,9 @@ function runPackageManagerSelfUpdate(manager) { releaseUpdateLease(); ({ liveness: recoveryLiveness, plan: recovery } = planRecovery()); } - if (recovery.reason === "ownership-unknown") { + if (recovery.reason === "desktop-supervised") { + console.log("OpenCodex Desktop supervises the proxy; no CLI runtime was restored."); + } else if (recovery.reason === "ownership-unknown") { console.error(`opencodex: ${reason}; runtime ownership is unknown, so automatic recovery was refused. Run 'ocx status --json' and repair the service-state record before retrying.`); } else if (recovery.reason === "ownership-transferred") { console.log("opencodex: runtime ownership moved to another installation; the stopped CLI runtime was not revived."); @@ -666,6 +762,14 @@ function runPackageManagerSelfUpdate(manager) { process.exit(1); } if (stopNeeded) { + const preStopPlan = planUpdateRuntimeHandling({ + ...readOwnership(), serviceInstalled: serviceWasInstalled, supervision: observeSupervision(), + }); + if (!preStopPlan.mayStopRuntime) { + console.error(preStopPlan.notice); + releaseUpdateLease(); + process.exit(1); + } stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); const stopRes = spawnSync(process.execPath, [launcher, "stop"], { @@ -722,7 +826,7 @@ function runPackageManagerSelfUpdate(manager) { } const replacementOwnership = readOwnership(); - const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled }); + const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); const replacementLiveness = currentPackageRuntimeLiveness(); if (replacementOwnership.subjectToken !== initialOwnership.subjectToken || !replacementPlan.mayReplacePackage @@ -829,9 +933,10 @@ function runPackageManagerSelfUpdate(manager) { // path and keeps token restoration coupled to the lease itself. releaseUpdateLease(); } - const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); + const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); if (res.status === 0) { console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); + if (!postInstallPlan.mayStopRuntime) console.warn("Runtime authority does not permit CLI recovery; no service was refreshed or proxy started."); repairCodexShimIfNeeded(postUpdateLauncher); if (trayBeforeUpdate.refreshAfterReplacement) { const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayBeforeUpdate.installArgs], { @@ -847,7 +952,8 @@ function runPackageManagerSelfUpdate(manager) { // launcher so the new files write the baked paths and the service restarts. if (postInstallPlan.mayRestoreService) { console.log("Refreshing the background service with the updated files..."); - refreshBackgroundServiceOrStartDirect(); + const refreshed = refreshBackgroundServiceOrStartDirect(); + if (refreshed === "unsupervised" || refreshed === "unsettled") process.exit(1); } else if (postInstallPlan.mayStopRuntime) { console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); } @@ -884,6 +990,8 @@ const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH"; // imported; tests/cli/ocx-launcher-source.test.ts pins the two together. const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE"; const BUN_RUNTIME_PATH_ENV = "OCX_BUN_RUNTIME_PATH"; +/** Total budget for validating a PATH Bun fallback (both probes together). */ +const PATH_BUN_PROBE_BUDGET_MS = 5_000; function findBunBinary(bunDir) { // The bundled `bun` package ships the binary as bin/bun.exe on every platform; @@ -899,6 +1007,7 @@ function fail(msg) { const reinstall = installMethod === "pnpm" ? "pnpm add -g --allow-build=bun @bitkyc08/opencodex" : "npm install -g --allow-scripts=bun @bitkyc08/opencodex"; + const desktopCli = findDesktopCli(); console.error( `opencodex: ${msg}\n` + "The bundled Bun runtime could not be prepared. This usually means the\n" + @@ -906,7 +1015,8 @@ function fail(msg) { "or pnpm did not approve bun's build) or optional dependencies. Reinstall with:\n" + ` ${reinstall}\n` + "(use sudo if the original install used sudo; without --ignore-scripts\n" + - "and without --omit=optional / optional=false)" + "and without --omit=optional / optional=false)" + + (desktopCli ? `\nAn installed Desktop CLI is available: "${desktopCli}"` : "") ); process.exit(1); } @@ -923,25 +1033,32 @@ function resolveBun({ allowInstall = true } = {}) { ); } - let bunDir; + let bunDir = null; try { bunDir = bunBinDir(); - } catch { - fail("the `bun` dependency is not installed."); - } + } catch { /* Missing dependency can still fall back to a validated PATH Bun. */ } - let bin = findBunBinary(bunDir); + let bin = bunDir ? findBunBinary(bunDir) : null; if (bin) return { path: bin, source: "bundled" }; // Lazy fallback: --ignore-scripts (or a failed postinstall) leaves the // ~450-byte placeholder stub. Run the bun package's own installer once. - const installJs = join(bunDir, "install.js"); - if (allowInstall && existsSync(installJs)) { + const installJs = bunDir ? join(bunDir, "install.js") : null; + if (allowInstall && installJs && existsSync(installJs)) { const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" }); if (r.status === 0) bin = findBunBinary(bunDir); } - if (!bin) fail("Bun binary missing after install attempt."); - return { path: bin, source: "bundled" }; + if (bin) return { path: bin, source: "bundled" }; + + // Reached only when the bundled runtime is unusable. The two probes start the candidate Bun + // cold, and on Windows a first run of a copied bun.exe is often held by an on-access scan for + // well over a second, so the total budget is generous rather than interactive-tight. + const pathBun = findPathBun({ pinnedVersion: pinnedBunVersion(), deadlineMs: PATH_BUN_PROBE_BUDGET_MS }); + if (pathBun) { + console.error(`opencodex: using PATH Bun ${pathBun.version}.`); + return { path: pathBun.path, source: "process" }; + } + fail(bunDir ? "Bun binary missing after install attempt." : "the `bun` dependency is not installed."); } // `ocx update --help` prints usage and exits WITHOUT side effects. The Node launcher @@ -961,6 +1078,36 @@ if (codexCliUpdateInspection && typeof process.versions.bun === "string") { process.exit(1); } +const { launchProof, launchContext, inheritedEnv } = createNodeLaunchContext(); +const desktopPlan = planDesktopCliHandoff({ selfPaths: [fileURLToPath(import.meta.url), process.execPath] }); +if (desktopPlan.kind === "error") { + if (desktopPlan.issue.startsWith("record-")) { + console.error(`opencodex: OpenCodex Desktop's terminal-command record at ${desktopCliRecordPath()} could not be used (${desktopPlan.issue}). Open OpenCodex Desktop to repair the terminal command, or run with OCX_NO_DESKTOP_HANDOFF=1.`); + } else { + console.error(`opencodex: Desktop CLI handoff refused (${desktopPlan.issue}). Repair the terminal command in OpenCodex Desktop, or run with OCX_NO_DESKTOP_HANDOFF=1.`); + } + process.exit(1); +} +if (desktopPlan.kind === "handoff") { + const result = await runDesktopCliHandoff(desktopPlan, { + argv: process.argv.slice(2), env: inheritedEnv, proof: launchProof, context: launchContext, + }); + if (result.kind === "error") { + console.error(`opencodex: Desktop CLI handoff failed (${result.issue}). Repair the terminal command in OpenCodex Desktop, or run with OCX_NO_DESKTOP_HANDOFF=1.`); + process.exit(127); + } + if (result.kind === "exit") { + if (result.signal) { + process.kill(process.pid, result.signal); + } else { + process.exit(result.code); + } + // Signal delivery is asynchronous; never resume package repair or Bun lookup. + await new Promise(() => {}); + } + // Only confirmed target ENOENT reaches the ordinary package path below. +} + if (process.argv[2] === "update" && installMethod === "mise") { if (installOwnership.owner) { console.error( @@ -1015,64 +1162,68 @@ const bun = bunRuntime.path; // billing and prevents it from redirecting the subscriber's OAuth bearer. // Disabling Bun's dotenv wholesale with --no-env-file is NOT an option: config // interpolation and provider settings legitimately read the project environment. -const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] - .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); -// A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the -// ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture -// the absolute form here, in the launcher, while the original cwd is still authoritative; -// resolving it later would silently reinterpret it against a different working directory. -// -// A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver -// deliberately hands those to executable lookup along PATH. Rewriting it to `/codex` -// would make the inspector treat it as an explicit path and stop searching PATH entirely. -const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== "" - ? process.env.CODEX_CLI_PATH - : null; -const preBunCodexCliPath = configuredCodexCliPath !== null - && (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)) - ? resolve(configuredCodexCliPath) - : configuredCodexCliPath; -const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; -const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; -const preBunCodexCliManagerRoots = Object.fromEntries( - CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { - const value = process.env[name]; - return typeof value === "string" && value !== "" ? [[name, value]] : []; - }), -); -const launchProof = randomBytes(32).toString("base64url"); -const launchContext = JSON.stringify({ - version: 1, - proof: launchProof, - anthropicEnvSlots: preBunAnthropicSlots, - codexCliInspectionEnv: codexCliUpdateInspection ? { - codexCliPath: preBunCodexCliPath, - path: preBunPath, - pathExt: preBunPathExt, - managerRoots: preBunCodexCliManagerRoots, - configDir: configDir(), - } : null, -}); -// The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as -// proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than -// from the live environment. Inheriting them again would spend the 32,767-character Windows -// environment block twice, so a large-but-valid shell environment could stop the Bun child -// from spawning and fail the command before it reports anything. Drop the duplicates for the -// one-shot inspection launch only; every other launch inherits the environment unchanged. -// Windows environment names are case-insensitive, but this spread produces an ordinary -// case-sensitive object, and a real Windows environment commonly spells the variable `Path`. -// Deleting only the canonical upper-case spelling would silently leave that copy behind and -// reintroduce the duplication this block exists to prevent, so match on the lowercase form. -const inheritedEnv = { ...process.env }; -if (codexCliUpdateInspection) { - const snapshotted = new Set( - ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()), +function createNodeLaunchContext() { + const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] + .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); + // A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the + // ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture + // the absolute form here, in the launcher, while the original cwd is still authoritative; + // resolving it later would silently reinterpret it against a different working directory. + // + // A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver + // deliberately hands those to executable lookup along PATH. Rewriting it to `/codex` + // would make the inspector treat it as an explicit path and stop searching PATH entirely. + const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== "" + ? process.env.CODEX_CLI_PATH + : null; + const preBunCodexCliPath = configuredCodexCliPath !== null + && (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)) + ? resolve(configuredCodexCliPath) + : configuredCodexCliPath; + const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; + const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; + const preBunCodexCliManagerRoots = Object.fromEntries( + CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { + const value = process.env[name]; + return typeof value === "string" && value !== "" ? [[name, value]] : []; + }), ); - for (const name of Object.keys(inheritedEnv)) { - if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name]; + const launchProof = randomBytes(32).toString("base64url"); + const launchContext = JSON.stringify({ + version: 1, + proof: launchProof, + anthropicEnvSlots: preBunAnthropicSlots, + codexCliInspectionEnv: codexCliUpdateInspection ? { + codexCliPath: preBunCodexCliPath, + path: preBunPath, + pathExt: preBunPathExt, + managerRoots: preBunCodexCliManagerRoots, + configDir: configDir(), + } : null, + }); + // The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as + // proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than + // from the live environment. Inheriting them again would spend the 32,767-character Windows + // environment block twice, so a large-but-valid shell environment could stop the Bun child + // from spawning and fail the command before it reports anything. Drop the duplicates for the + // one-shot inspection launch only; every other launch inherits the environment unchanged. + // Windows environment names are case-insensitive, but this spread produces an ordinary + // case-sensitive object, and a real Windows environment commonly spells the variable `Path`. + // Deleting only the canonical upper-case spelling would silently leave that copy behind and + // reintroduce the duplication this block exists to prevent, so match on the lowercase form. + const inheritedEnv = { ...process.env }; + if (codexCliUpdateInspection) { + const snapshotted = new Set( + ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()), + ); + for (const name of Object.keys(inheritedEnv)) { + if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name]; + } } + return { launchProof, launchContext, inheritedEnv }; } -const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2)], { + +const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2).filter(arg => !arg.startsWith(NODE_LAUNCH_PROOF_PREFIX))], { stdio: "inherit", // A headless Windows parent (Task Scheduler, dashboard restart, shortcut) has no // console to inherit. Without this flag Windows allocates a visible console for diff --git a/bun.lock b/bun.lock index 4c123e4c3c3..063207baa8b 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@bufbuild/protobuf": "^2.14.0", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", - "bun": "1.4.0", + "bun": "1.4.2", "zod": "4.4.3", }, "devDependencies": { @@ -35,7 +35,7 @@ "@hono/node-server": ["@hono/node-server@2.1.0", "", { "peerDependencies": { "hono": "^4" } }, "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.31.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw=="], "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], @@ -63,29 +63,29 @@ "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GCpf8QuFLsyioVawP5HrMxA1ZRBlu6Hq9RNnSc3UTUWAzIxBso9trjoZczw1HdgpqSssFkszfIV2zmOzFTjhkw=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="], - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cIrhwOr0SPEraewznhC+c/k6TG8bwFn5uZ4EJuXwjiKJLcAF36q7/bGjWkeXSe48JwMcPRUR054JXF7+cRwSSA=="], + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="], - "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-09x7wnjMR6M5KGBDBhVl2CpfoCIQOkVDbPX2KfIhpXv4N6grbWE7dfLPw/Ydi9gaUMGhU7UKhoz444Nu6RCycA=="], + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ=="], - "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dRwzti/qJqV1HWplU27iUWUqp+f2DtFSf2yqQKSb+HH2dDOC//Uqd9u/A5h1DMsLszfP5OGP9UwQIKxVwFODaA=="], + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw=="], - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y5yAtCbHK6JjprXEtkdklDQFPADgs+CkfcliyY5g4JJ8baGHyQSrfpSkX3XVJ2C+aBLsdwNDdW+oczMsAwx6uA=="], + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="], - "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.0", "", { "os": "android", "cpu": "arm64" }, "sha512-HpPIxJfDNPBPhiBNMyZoo/dOLijARfsx5j72vNuLtaTvl0Hh7HUculxjsOQ2WSyGoCgqXMEr1Qqjab1im9u1RA=="], + "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.2", "", { "os": "android", "cpu": "arm64" }, "sha512-3mZKO2rhsNgbAUtAHC1UKUlF2zTxFraDZT/Elv8wzyH0fJL9h+Iv3TgB9lO63w89PRn3eFe+NRA1bhVgikKNPQ=="], - "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RUjAAkJ/CdNV++zVxyANWshPc73CECYsfhk0fWAkoJjtywxJ2BwXzI6nopBBDMfs0HS+fhRGn6zGwU8ccxLeJg=="], + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sm6y+lSiSFBOtXmnekp5Q6n1tUKlyv71FCPWBc61Cgb14T5eBs8SN/nh4MUCOKzONkI3O+as3MGUgikS4aCBQ=="], - "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Du44zebtPXJujvMLmtIxEQ6ykOhYt7L/Q+YIGVm+Yy+Pj/fpOnq60ggwIpKp/pGAFbYHNiTrA3JTjuZ9MTbZIg=="], + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng=="], - "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.0", "", { "os": "android", "cpu": "x64" }, "sha512-u++KyLlfMn36yWz+AgJs+fZtS46UFDNpSSZhrcitkytONtNwq0X6Q9BDVEFXxYl/+Eec0xme1rb6MgW+U35WeA=="], + "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.2", "", { "os": "android", "cpu": "x64" }, "sha512-6HC5tzcC79113n2IHCTJMWv+HsQImv4ZFEK2XpYLxY6HbT8tM4cUM2Zv1bHZBQsS3jv/zYBamDJ1UX7If0d5tw=="], - "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-C1Dv+ISL8YKEKM9jAHzNifOcRUoziy6UMxh+yVXjUCP6QnbRhENDHLaIWWkQZJyBLTn0I3xozflorAlHiGzGqA=="], + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw=="], - "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-FBAYaQpJBP0asgqzL6NFUfjdQqsV+kvTpJ/eWxPKj+RcDgIfPSuE8kvQuPYu5pa8u8JTujYMjmuyvHxVuQsInA=="], + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q=="], - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jRKv1NPLznMSZY5BEWciMF7zv0Tiyo2pQSxAJ3w+YWJ6y3VWNJQQQdLlV5Jx8lbOFDrJdrc9dD3GV17k3BP41A=="], + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g=="], "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], @@ -139,7 +139,7 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "bun": ["bun@1.4.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.0", "@oven/bun-darwin-x64": "1.4.0", "@oven/bun-freebsd-aarch64": "1.4.0", "@oven/bun-freebsd-x64": "1.4.0", "@oven/bun-linux-aarch64": "1.4.0", "@oven/bun-linux-aarch64-android": "1.4.0", "@oven/bun-linux-aarch64-musl": "1.4.0", "@oven/bun-linux-x64": "1.4.0", "@oven/bun-linux-x64-android": "1.4.0", "@oven/bun-linux-x64-musl": "1.4.0", "@oven/bun-windows-aarch64": "1.4.0", "@oven/bun-windows-x64": "1.4.0" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-iRiFkc2W7UVpCyZXO9tod45TP9QCyN19fWqbpeN/jaM/K7uzeHYx/OSPsahMJazGKBgPsnxRt+4Jc43d8BcHZw=="], + "bun": ["bun@1.4.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.2", "@oven/bun-darwin-x64": "1.4.2", "@oven/bun-freebsd-aarch64": "1.4.2", "@oven/bun-freebsd-x64": "1.4.2", "@oven/bun-linux-aarch64": "1.4.2", "@oven/bun-linux-aarch64-android": "1.4.2", "@oven/bun-linux-aarch64-musl": "1.4.2", "@oven/bun-linux-x64": "1.4.2", "@oven/bun-linux-x64-android": "1.4.2", "@oven/bun-linux-x64-musl": "1.4.2", "@oven/bun-windows-aarch64": "1.4.2", "@oven/bun-windows-x64": "1.4.2" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA=="], "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], @@ -191,7 +191,7 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -219,7 +219,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ip-address": ["ip-address@10.4.0", "", {}, "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ=="], + "ip-address": ["ip-address@10.7.1", "", {}, "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -263,7 +263,7 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 549f7225a17..35894c7cd1e 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.78.0-preview.20261005" +version = "2.85.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 64b3655e052..ede2061caef 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.78.0-preview.20261005" +version = "2.85.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/src/cli_command.rs b/desktop/src-tauri/src/cli_command.rs new file mode 100644 index 00000000000..a89361e9fd7 --- /dev/null +++ b/desktop/src-tauri/src/cli_command.rs @@ -0,0 +1,775 @@ +#[cfg(unix)] +use crate::cli_command_posix as posix; +use crate::{ + cli_command_record::{self as record, Bundle, Record, Result, Store}, + cli_command_windows as windows, +}; +use serde::Serialize; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; +use tauri::{AppHandle, Manager}; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Status { + pub enabled: bool, + pub configured: bool, + pub phase: String, + pub expected_executable: Option, + pub issues: Vec, +} +impl Default for Status { + fn default() -> Self { + Self { + enabled: true, + configured: false, + phase: "unobserved".into(), + expected_executable: None, + issues: Vec::new(), + } + } +} +#[derive(Default)] +pub struct State { + scheduled: AtomicBool, + serial: Mutex<()>, + latest: Mutex, +} +#[derive(Clone, Copy)] +pub enum Action { + Reconcile, + Repair, + Enable(bool), + Remove, +} +pub fn status(app: &AppHandle) -> Status { + app.state::() + .latest + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} +fn perform(app: &AppHandle, action: Action) -> Result { + let home = app.path().home_dir().map_err(|_| "home-unavailable")?; + if !home.is_absolute() { + return Err("home-unavailable".into()); + } + record::check(&home, true)?; + let root = home.join(".opencodex-desktop"); + let exists = root.try_exists().map_err(|_| "io-failed")?; + // First run checks the bundle before creating even the record directory (AM-10). + let missing = !exists + || !root + .join("cli.json") + .try_exists() + .map_err(|_| "io-failed")?; + let disabled = matches!(action, Action::Remove | Action::Enable(false)); + let observed = if missing && !disabled { + Some(observe_bundle(app)?) + } else { + None + }; + let initial = if missing { + let mut r = match &observed { + Some(b) => Record::fresh(b.clone()), + None => Record { + version: 1, + owner_id: uuid::Uuid::new_v4().to_string(), + install_id: String::new(), + generation: 1, + enabled: false, + bundle: None, + posix: None, + windows: None, + notify_pending: false, + pending: None, + }, + }; + r.install_id = crate::identity::install_id(app).ok_or("install-id-unavailable")?; + Some(r) + } else { + None + }; + perform_selected( + root, + &home, + action, + initial, + || match observed { + Some(b) => Ok(b), + None => observe_bundle(app), + }, + |enabled| { + app.state::() + .latest + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .enabled = enabled; + }, + ) +} +fn perform_selected( + root: std::path::PathBuf, + home: &std::path::Path, + action: Action, + initial: Option, + observe: impl FnOnce() -> Result, + publish_enabled: impl FnOnce(bool), +) -> Result { + let mut store = Store::open(root, Vec::new())?; + let current = store.read()?; + let enabled = match action { + Action::Remove | Action::Enable(false) => false, + Action::Enable(true) => true, + _ => current + .as_ref() + .or(initial.as_ref()) + .is_some_and(|r| r.enabled), + }; + #[cfg(unix)] + let selected = if enabled { + posix::targets(home)? + } else { + Vec::new() + }; + #[cfg(not(unix))] + let selected: Vec<(String, std::path::PathBuf)> = { + let _ = enabled; + Vec::new() + }; + store.rc_allowed = selected.iter().map(|(_, p)| p.clone()).collect(); + perform_in( + &store, + home, + selected, + action, + initial, + observe, + publish_enabled, + ) +} +fn observe_bundle(app: &AppHandle) -> Result { + let exe = std::env::current_exe().map_err(|_| "bundle-unavailable")?; + let version = app.package_info().version.to_string(); + #[cfg(unix)] + { + posix::stable_bundle(&exe, cfg!(debug_assertions), &version) + } + #[cfg(windows)] + { + windows::stable_bundle(&exe, cfg!(debug_assertions), &version) + } +} +fn perform_in( + store: &Store, + home: &std::path::Path, + selected: Vec<(String, std::path::PathBuf)>, + action: Action, + initial: Option, + observe: impl FnOnce() -> Result, + publish_enabled: impl FnOnce(bool), +) -> Result { + // Initial/persisted records contain ownership history, never evidence of this launch. + let mut r = match store.read()? { + Some(r) => r, + None => initial.ok_or("bundle-unavailable")?, + }; + let explicit_remove = matches!(action, Action::Remove | Action::Enable(false)); + let desired = match action { + Action::Enable(v) => Some(v), + Action::Remove => Some(false), + _ => None, + }; + let install_bundle = if desired == Some(true) || (desired != Some(false) && r.enabled) { + Some(observe()?) + } else { + None + }; + if desired == Some(true) { + r.bundle = install_bundle.clone(); + } + if let Some(enabled) = desired { + if r.enabled != enabled { + r.enabled = enabled; + r.generation += 1; + if let Some(j) = &mut r.pending { + j.next.enabled = enabled; + j.next.generation = r.generation + 1; + } + } + // Off is durable even if journal conflict prevents this cleanup attempt. + store.save(&r)?; + } + publish_enabled(r.enabled); + // A disabled interrupted install rolls back its completed prefix. It never finishes installing. + store.recover(&mut r)?; + // Retry settled/recovery debt before target planning, which may itself fail. + let mut notify_issues = Vec::new(); + if let Err(e) = store.notify_with(&mut r, windows::notify) { + notify_issues.push(e); + } + let remove = explicit_remove || (!r.enabled && matches!(action, Action::Reconcile)); + if !remove && !r.enabled { + return Ok(Status { + enabled: false, + phase: if notify_issues.is_empty() { + "disabled" + } else { + "partial" + } + .into(), + issues: notify_issues, + ..Status::default() + }); + } + let (next, changes, mut issues) = if remove { + #[cfg(unix)] + { + posix::remove_plan(store, &r)? + } + #[cfg(windows)] + { + if let Some(b) = r.bundle.clone() { + windows::plan(&r, b, true)? + } else { + (r.clone(), Vec::new(), Vec::new()) + } + } + } else { + let b = install_bundle.ok_or("bundle-unavailable")?; + #[cfg(unix)] + { + posix::plan(store, &r, b, home, selected)? + } + #[cfg(windows)] + { + let _ = (home, selected); + windows::plan(&r, b, false)? + } + }; + let registry_changed = changes.iter().any(|c| c.kind.starts_with("registry-")); + store.transact( + &mut r, + next, + changes, + if remove { "remove" } else { "install" }, + )?; + issues.extend(notify_issues); + issues.extend(store.journal_issues(&r)); + if registry_changed { + if let Err(e) = store.notify_with(&mut r, windows::notify) { + issues.push(e); + } + } + #[cfg(windows)] + if !remove && r.enabled { + if let Some(owned) = &r.windows { + match windows::machine_conflict(&owned.entry) { + Ok(true) => issues.push("machine-path-conflict".into()), + Ok(false) => {} + Err(e) => issues.push(e), + } + } + } + let configured = r.enabled + && r.bundle.is_some() + && (r.posix.is_some() || r.windows.is_some()) + && issues.is_empty(); + let phase = if !issues.is_empty() { + "partial" + } else if !r.enabled { + "disabled" + } else if configured { + "configured" + } else { + "unobserved" + }; + Ok(Status { + enabled: r.enabled, + configured, + phase: phase.into(), + expected_executable: r.bundle.as_ref().map(|b| b.cli_executable.clone()), + issues, + }) +} +fn run(app: &AppHandle, action: Action) -> Status { + let state = app.state::(); + let _serial = state + .serial + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let result = match perform(app, action) { + Ok(v) => v, + Err(code) => { + crate::logging::log_once("terminal command", &code); + let mut v = status(app); + v.configured = false; + v.phase = if v.enabled { "blocked" } else { "partial" }.into(); + v.issues = if v.enabled { + vec![code] + } else { + vec![code, "cleanup-pending".into()] + }; + v + } + }; + *state + .latest + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = result.clone(); + result +} +async fn execute(app: AppHandle, action: Action) -> Result { + let worker_app = app.clone(); + match tauri::async_runtime::spawn_blocking(move || run(&worker_app, action)).await { + Ok(v) => Ok(v), + Err(_) => { + let mut v = status(&app); + v.configured = false; + v.phase = "blocked".into(); + v.issues = vec!["worker-failed".into()]; + *app.state::() + .latest + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = v.clone(); + Ok(v) + } + } +} +pub fn reconcile_on_launch(app: &AppHandle) { + if app.state::().scheduled.swap(true, Ordering::AcqRel) { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if execute(app, Action::Reconcile).await.is_err() { + crate::logging::log_once("terminal command", "worker-failed"); + } + }); +} +pub async fn set_enabled(app: AppHandle, enabled: bool) -> Result { + execute(app, Action::Enable(enabled)).await +} +pub async fn install(app: AppHandle) -> Result { + execute(app, Action::Repair).await +} +pub async fn remove(app: AppHandle) -> Result { + execute(app, Action::Remove).await +} +pub fn show_page(app: &AppHandle) { + crate::popup::hide(app); + if let Some(w) = app.get_webview_window("main") { + // Fixed local URL; this works before a proxy exists, like the bundled update page. + let origin = if cfg!(target_os = "windows") { + "http://tauri.localhost/cli.html" + } else { + "tauri://localhost/cli.html" + }; + if tauri::Url::parse(origin) + .ok() + .is_some_and(|url| w.navigate(url).is_ok()) + { + crate::window::show(&w); + } else { + crate::logging::log_once("terminal command", "page-unavailable"); + } + } +} + +#[cfg(test)] +mod bootstrap_tests { + use super::*; + use record::tests::{bundle, Temp}; + #[test] + fn first_off_and_remove_persist_disabled_intent_without_observing_a_bundle() { + for action in [Action::Enable(false), Action::Remove] { + let t = Temp::new(); + let root = t.0.join("record"); + let mut initial = Record::fresh(bundle()); + initial.enabled = false; + initial.bundle = None; + let status = perform_selected( + root.clone(), + &t.0, + action, + Some(initial), + || panic!("disabled-must-not-observe-bundle"), + |_| {}, + ) + .unwrap(); + assert!(!status.enabled); + let store = Store::open(root, vec![]).unwrap(); + let r = store.read().unwrap().unwrap(); + assert!(!r.enabled && r.bundle.is_none()); + } + } +} + +#[cfg(all(test, windows))] +mod windows_tests { + use super::*; + use record::tests::{bundle, Temp}; + #[test] + fn selected_entry_refuses_unstable_bundle_before_registry_access() { + for action in [Action::Reconcile, Action::Repair, Action::Enable(true)] { + for error in ["development-launch", "temporary-bundle"] { + let t = Temp::new(); + let store = t.store(); + store.save(&Record::fresh(bundle())).unwrap(); + let before = std::fs::read(store.root.join("cli.json")).unwrap(); + let root = store.root.clone(); + drop(store); + let calls = std::cell::Cell::new(0); + let result = perform_selected( + root.clone(), + &t.0, + action, + None, + || { + calls.set(calls.get() + 1); + Err(error.into()) + }, + // A regressed observation shortcut must stop before reaching real registry IO. + |_| assert_eq!(calls.get(), 1, "bundle-observation-skipped"), + ); + assert_eq!(calls.get(), 1, "bundle-observation-skipped"); + assert_eq!(result.unwrap_err(), error); + assert!( + std::fs::read(root.join("cli.json")).unwrap() == before, + "unstable-bundle-changed-record" + ); + } + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use record::tests::{bundle, Temp}; + fn selected(t: &Temp) -> Vec<(String, std::path::PathBuf)> { + vec![("zsh".into(), t.0.join(".zshrc"))] + } + fn selected_observation_case(case: &str) { + let t = Temp::new(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "cli_command::tests::selected_observation_child", + "--nocapture", + ]) + .env("OCX_CLI_OBSERVATION_TEST", case) + .env("HOME", &t.0) + .env("ZDOTDIR", &t.0) + .env("XDG_CONFIG_HOME", t.0.join(".config")) + .output() + .unwrap(); + assert!(result.status.success(), "selected-observation-failed"); + assert!( + String::from_utf8_lossy(&result.stdout).contains("1 passed"), + "selected-observation-not-executed" + ); + } + #[test] + fn selected_entry_refreshes_bundle_path_version_and_shim() { + for action in ["reconcile", "repair", "enable"] { + selected_observation_case(&format!("update-{action}")); + } + } + #[test] + fn selected_entry_refuses_unstable_bundle_and_preserves_existing_files() { + for action in ["reconcile", "repair", "enable"] { + for error in ["development-launch", "temporary-bundle"] { + selected_observation_case(&format!("{error}-{action}")); + } + } + } + #[test] + fn selected_observation_child() { + let Ok(case) = std::env::var("OCX_CLI_OBSERVATION_TEST") else { + return; + }; + let home = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()); + let rc = home.join(".zshrc"); + let root = home.join("record"); + let store = Store::open(root.clone(), vec![rc.clone()]).unwrap(); + perform_in( + &store, + &home, + vec![("zsh".into(), rc.clone())], + Action::Reconcile, + Some(Record::fresh(bundle())), + || Ok(bundle()), + |_| {}, + ) + .unwrap(); + let snapshots: Vec<_> = [ + root.join("cli.json"), + root.join("bin/ocx"), + root.join("path.sh"), + rc, + ] + .into_iter() + .map(|path| { + let bytes = std::fs::read(&path).unwrap(); + (path, bytes) + }) + .collect(); + drop(store); + let (scenario, action) = case.rsplit_once('-').unwrap(); + let action = match action { + "reconcile" => Action::Reconcile, + "repair" => Action::Repair, + "enable" => Action::Enable(true), + _ => panic!("observation-test-invalid"), + }; + let calls = std::cell::Cell::new(0); + let mut moved = bundle(); + moved.app_executable = home + .join("Moved.app/Contents/MacOS/app") + .to_string_lossy() + .into_owned(); + moved.cli_executable = home + .join("Moved.app/Contents/MacOS/ocx") + .to_string_lossy() + .into_owned(); + moved.version = "2".into(); + let result = perform_selected( + root.clone(), + &home, + action, + None, + || { + calls.set(calls.get() + 1); + if scenario == "update" { + Ok(moved.clone()) + } else { + Err(scenario.into()) + } + }, + |_| {}, + ); + assert_eq!(calls.get(), 1, "bundle-observation-skipped"); + if scenario == "update" { + result.unwrap(); + let store = Store::open(root.clone(), vec![]).unwrap(); + let r = store.read().unwrap().unwrap(); + assert!(r.bundle.as_ref() == Some(&moved), "bundle-not-refreshed"); + let expected = + posix::render_shim(std::path::Path::new(&moved.cli_executable), &r.owner_id) + .unwrap(); + assert!( + std::fs::read(root.join("bin/ocx")).unwrap() == expected.as_bytes(), + "shim-target-not-refreshed" + ); + } else { + assert_eq!(result.unwrap_err(), scenario); + for (path, bytes) in snapshots { + assert!( + std::fs::read(path).unwrap() == bytes, + "unstable-bundle-changed-files" + ); + } + assert!(!home.join(".zlogin").exists()); + assert!(!home.join(".bashrc").exists()); + assert!(!home.join(".config/fish/config.fish").exists()); + } + } + fn disabled_selector_case(action: &str) { + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "cli_command::tests::disabled_selector_child", + "--nocapture", + ]) + .env("OCX_CLI_SELECTOR_TEST", action) + .env("ZDOTDIR", "/invalid:zdotdir") + .output() + .unwrap(); + assert!(result.status.success(), "disabled-selector-failed"); + assert!( + String::from_utf8_lossy(&result.stdout).contains("1 passed"), + "disabled-selector-not-executed" + ); + } + #[test] + fn remove_uses_recorded_targets_with_unrepresentable_zdotdir() { + disabled_selector_case("remove"); + } + #[test] + fn off_uses_recorded_targets_with_unrepresentable_zdotdir() { + disabled_selector_case("off"); + } + #[test] + fn disabled_reconcile_uses_recorded_targets_with_unrepresentable_zdotdir() { + disabled_selector_case("reconcile"); + } + #[test] + fn disabled_selector_child() { + let Ok(action) = std::env::var("OCX_CLI_SELECTOR_TEST") else { + return; + }; + let t = Temp::new(); + assert_eq!(posix::targets(&t.0).unwrap_err(), "path-unrepresentable"); + let store = t.store(); + perform_in( + &store, + &t.0, + selected(&t), + Action::Reconcile, + Some(Record::fresh(bundle())), + || Ok(bundle()), + |_| {}, + ) + .unwrap(); + let action = match action.as_str() { + "remove" => Action::Remove, + "off" => Action::Enable(false), + "reconcile" => { + let mut r = store.read().unwrap().unwrap(); + r.enabled = false; + store.save(&r).unwrap(); + Action::Reconcile + } + _ => panic!("selector-test-invalid"), + }; + let root = store.root.clone(); + drop(store); + let status = perform_selected( + root.clone(), + &t.0, + action, + None, + || panic!("disabled-must-not-observe-bundle"), + |_| {}, + ) + .unwrap(); + assert!(!status.enabled); + assert!(!t.0.join(".zshrc").exists()); + assert!(!root.join("bin/ocx").exists()); + assert!(!root.join("path.sh").exists()); + } + #[test] + fn fresh_stable_bundle_installs_without_npm() { + let t = Temp::new(); + let store = t.store(); + let result = perform_in( + &store, + &t.0, + selected(&t), + Action::Reconcile, + Some(Record::fresh(bundle())), + || Ok(bundle()), + |_| {}, + ) + .unwrap(); + assert!(result.enabled && result.configured); + let r = store.read().unwrap().unwrap(); + assert!(r.bundle.is_some() && r.pending.is_none()); + assert!(store.root.join("bin/ocx").is_file()); + let generation = r.generation; + perform_in( + &store, + &t.0, + selected(&t), + Action::Reconcile, + None, + || Ok(bundle()), + |_| {}, + ) + .unwrap(); + assert_eq!(store.read().unwrap().unwrap().generation, generation); + } + #[test] + fn disabled_record_survives_relaunch_repair_and_tombstone_can_be_enabled() { + let t = Temp::new(); + let store = t.store(); + let mut r = Record::fresh(bundle()); + r.enabled = false; + r.bundle = None; + store.save(&r).unwrap(); + for action in [Action::Reconcile, Action::Repair] { + let result = perform_in( + &store, + &t.0, + selected(&t), + action, + None, + || panic!("off does not observe an install target"), + |_| {}, + ) + .unwrap(); + assert!(!result.enabled); + assert_eq!(result.phase, "disabled"); + assert!(!store.root.join("bin/ocx").exists()); + } + let result = perform_in( + &store, + &t.0, + selected(&t), + Action::Enable(true), + None, + || Ok(bundle()), + |_| {}, + ) + .unwrap(); + assert!(result.configured); + } + #[test] + fn remove_persists_disabled_intent_before_first_cleanup_write() { + let t = Temp::new(); + let store = t.store(); + perform_in( + &store, + &t.0, + selected(&t), + Action::Reconcile, + Some(Record::fresh(bundle())), + || Ok(bundle()), + |_| {}, + ) + .unwrap(); + let shim = store.root.join("bin/ocx"); + let result = perform_in( + &store, + &t.0, + selected(&t), + Action::Remove, + None, + || panic!("remove has no bundle gate"), + |enabled| { + assert!(!enabled); + assert!(!store.read().unwrap().unwrap().enabled); + assert!(shim.exists()); + }, + ) + .unwrap(); + assert!(!result.enabled); + assert_eq!(result.phase, "disabled"); + assert!(!shim.exists()); + } + #[test] + fn unstable_bundle_keeps_existing_record_and_files_unchanged() { + let t = Temp::new(); + let store = t.store(); + let r = Record::fresh(bundle()); + store.save(&r).unwrap(); + let before = std::fs::read(store.root.join("cli.json")).unwrap(); + assert_eq!( + perform_in( + &store, + &t.0, + selected(&t), + Action::Reconcile, + None, + || Err("development-launch".into()), + |_| {} + ) + .unwrap_err(), + "development-launch" + ); + assert_eq!(std::fs::read(store.root.join("cli.json")).unwrap(), before); + } +} diff --git a/desktop/src-tauri/src/cli_command_posix.rs b/desktop/src-tauri/src/cli_command_posix.rs new file mode 100644 index 00000000000..8ab0fcf8295 --- /dev/null +++ b/desktop/src-tauri/src/cli_command_posix.rs @@ -0,0 +1,1281 @@ +use crate::cli_command_record::{ + self as record, Bundle, Change, OwnedFile, Posix, RcFile, Record, Result, Store, +}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +pub const START: &str = "# >>> OpenCodex Desktop ocx PATH v1 >>>"; +pub const END: &str = "# <<< OpenCodex Desktop ocx PATH v1 <<<"; +const SHIM: &str = r#"#!/bin/sh +# OpenCodex Desktop ocx shim v1 +# owner-id: @OWNER@ +_ocx_cli=@CLI@ +if [ ! -f "$_ocx_cli" ] || [ ! -x "$_ocx_cli" ]; then + printf '%s\n' 'OpenCodex Desktop CLI is unavailable. Repair or remove the terminal command in Desktop.' >&2 + exit 127 +fi +unset OCX_NODE_LAUNCH_CONTEXT OCX_PRE_BUN_ANTHROPIC_ENV +_ocx_proof= +if [ -r /dev/urandom ] && [ -x /usr/bin/od ] && [ -x /usr/bin/awk ]; then + _ocx_proof=$(/usr/bin/od -An -tu1 -N32 /dev/urandom 2>/dev/null | /usr/bin/awk ' + BEGIN { a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"; n=0; bits=0; count=0; out="" } + { for (i=1; i<=NF; i++) { + if ($i !~ /^[0-9]+$/ || $i < 0 || $i > 255) exit 1 + count++; n=n*256+$i; bits+=8 + while (bits>=6) { bits-=6; q=int(n/(2^bits)); out=out substr(a,q+1,1); n=n-q*(2^bits) } + } } + END { if (count!=32) exit 1; if (bits) out=out substr(a,n*(2^(6-bits))+1,1); print out } + ') || _ocx_proof= +fi +case "$_ocx_proof" in + *[!A-Za-z0-9_-]*) _ocx_proof= ;; +esac +if [ "${#_ocx_proof}" -eq 43 ]; then + _ocx_slots= + _ocx_sep= + if [ -n "${ANTHROPIC_API_KEY-}" ]; then + _ocx_slots='"ANTHROPIC_API_KEY"'; _ocx_sep=, + fi + if [ -n "${ANTHROPIC_AUTH_TOKEN-}" ]; then + _ocx_slots=$_ocx_slots$_ocx_sep'"ANTHROPIC_AUTH_TOKEN"'; _ocx_sep=, + fi + if [ -n "${ANTHROPIC_BASE_URL-}" ]; then + _ocx_slots=$_ocx_slots$_ocx_sep'"ANTHROPIC_BASE_URL"' + fi + OCX_NODE_LAUNCH_CONTEXT='{"version":1,"proof":"'$_ocx_proof'","anthropicEnvSlots":['$_ocx_slots'],"codexCliInspectionEnv":null}' + export OCX_NODE_LAUNCH_CONTEXT + exec "$_ocx_cli" "--ocx-internal-launch-proof=$_ocx_proof" "$@" +fi +exec "$_ocx_cli" "$@" +"#; +const HELPER: &str = r#"# OpenCodex Desktop ocx PATH helper v1 +_ocx_desktop_path_v1() ( + _ocx_bin=@BIN@ + _ocx_new=$_ocx_bin + _ocx_rest=${PATH-} + if [ "${PATH+x}" = x ]; then + while :; do + case "$_ocx_rest" in + *:*) _ocx_part=${_ocx_rest%%:*}; _ocx_rest=${_ocx_rest#*:}; _ocx_last=0 ;; + *) _ocx_part=$_ocx_rest; _ocx_last=1 ;; + esac + if [ "$_ocx_part" != "$_ocx_bin" ]; then _ocx_new=$_ocx_new:$_ocx_part; fi + [ "$_ocx_last" = 1 ] && break + done + fi + printf '%s' "$_ocx_new" +) +PATH=$(_ocx_desktop_path_v1) +export PATH +unset -f _ocx_desktop_path_v1 +"#; +fn text(p: &Path) -> Result { + let s = p.to_str().ok_or("path-not-utf8")?; + if !p.is_absolute() + || p.components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + || s.contains(['\0', '\n', '\r', ':']) + { + return Err("path-unrepresentable".into()); + } + Ok(s.into()) +} +fn quote(p: &Path) -> Result { + Ok(format!("'{}'", text(p)?.replace('\'', "'\"'\"'"))) +} +pub fn render_shim(cli: &Path, owner: &str) -> Result { + // Replace the owner before substituting paths so a literal placeholder in a path stays literal. + Ok(SHIM + .replace("@OWNER@", owner) + .replace("@CLI@", "e(cli)?)) +} +pub fn render_helper(bin: &Path) -> Result { + Ok(HELPER.replace("@BIN@", "e(bin)?)) +} +const TEMPORARY_ROOTS: [&str; 6] = [ + "/tmp", + "/private/tmp", + "/var/tmp", + "/private/var/tmp", + "/var/folders", + "/private/var/folders", +]; +/// A caller-selected TMPDIR only proves transience when it resolves inside a recognized +/// temporary root or is itself a shared sticky directory. An arbitrary override such as +/// TMPDIR=$HOME can name an ordinary ancestor of a permanent install. +fn environment_temp_root(temp_dir: &Path) -> Option { + let real = fs::canonicalize(temp_dir).ok()?; + if TEMPORARY_ROOTS + .iter() + .map(Path::new) + .any(|root| real.starts_with(root)) + { + return Some(real); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if fs::metadata(&real) + .map(|m| m.permissions().mode() & 0o1777 == 0o1777) + .unwrap_or(false) + { + return Some(real); + } + } + None +} +fn is_temporary_bundle_path(path: &Path, temp_dir: &Path) -> bool { + let env_root = environment_temp_root(temp_dir); + TEMPORARY_ROOTS + .iter() + .map(Path::new) + .chain(env_root.as_deref()) + .any(|root| path.starts_with(root)) + || path.starts_with("/Volumes") +} +fn has_app_translocation_marker(path: &Path) -> bool { + path.components() + .any(|component| component.as_os_str().to_str() == Some("AppTranslocation")) +} +pub fn block(shell: &str, root: &Path) -> Result { + let body = if shell == "fish" { + format!( + "if status is-interactive\n fish_add_path --path --prepend --move {}\nend\n", + quote(&root.join("bin"))? + ) + } else { + let helper = quote(&root.join("path.sh"))?; + format!("if [ -r {helper} ]; then\n . {helper}\nfi\n") + }; + Ok(format!("{START}\n{body}{END}\n")) +} +pub fn targets(home: &Path) -> Result> { + targets_in( + home, + std::env::var_os("ZDOTDIR").map(PathBuf::from), + std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), + ) +} +fn targets_in( + home: &Path, + zdotdir: Option, + xdg: Option, +) -> Result> { + let z = zdotdir + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.into()); + let fish = xdg + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.join(".config")) + .join("fish/config.fish"); + let mut out = vec![ + ("zsh".into(), z.join(".zshrc")), + ("zsh".into(), z.join(".zlogin")), + ("bash".into(), home.join(".bashrc")), + ("fish".into(), fish), + ]; + // Exists, including an unsafe symlink: it is the active candidate and must be refused, not skipped. + if let Some(p) = [".bash_profile", ".bash_login", ".profile"] + .into_iter() + .map(|n| home.join(n)) + .find(|p| fs::symlink_metadata(p).is_ok()) + { + out.push(("bash".into(), p)); + } + for (_, p) in &out { + text(p)?; + } + Ok(out) +} +pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { + if debug { + return Err("development-launch".into()); + } + text(exe)?; + if has_app_translocation_marker(exe) + || is_temporary_bundle_path(exe, &std::env::temp_dir()) + || std::env::var_os("APPIMAGE").is_some() + || std::env::var_os("APPDIR").is_some() + { + return Err("temporary-bundle".into()); + } + let real = fs::canonicalize(exe).map_err(|_| "bundle-unavailable")?; + let s = text(&real)?; + if has_app_translocation_marker(&real) || is_temporary_bundle_path(&real, &std::env::temp_dir()) + { + return Err("temporary-bundle".into()); + } + let parent = real.parent().ok_or("unpackaged-launch")?; + let (platform, kind, cli) = if cfg!(target_os = "macos") { + if parent.file_name().and_then(|s| s.to_str()) != Some("MacOS") + || parent + .parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + != Some("Contents") + || parent + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.extension()) + .and_then(|s| s.to_str()) + != Some("app") + { + return Err("unpackaged-launch".into()); + } + ("darwin", "macos-app", parent.join("ocx")) + } else { + if parent != Path::new("/usr/bin") { + return Err("unpackaged-launch".into()); + } + ("linux", "linux-deb", PathBuf::from("/usr/bin/ocx")) + }; + let m = fs::symlink_metadata(&cli).map_err(|_| "bundle-cli-missing")?; + if !m.is_file() || m.file_type().is_symlink() { + return Err("bundle-cli-missing".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if m.permissions().mode() & 0o111 == 0 { + return Err("bundle-cli-not-executable".into()); + } + } + Ok(Bundle { + platform: platform.into(), + kind: kind.into(), + app_executable: s, + cli_executable: text(&cli)?, + version: version.into(), + }) +} + +#[cfg(test)] +mod temporary_bundle_path_tests { + use super::{has_app_translocation_marker, is_temporary_bundle_path}; + use std::path::Path; + + #[test] + fn temporary_bundle_roots_are_component_aware() { + let temp_dir = Path::new("/var/folders/test/app"); + for path in [ + "/tmp/App.app/Contents/MacOS/app", + "/private/tmp/App.app/Contents/MacOS/app", + "/var/tmp/App.app/Contents/MacOS/app", + "/private/var/tmp/App.app/Contents/MacOS/app", + "/var/folders/xx/App.app/Contents/MacOS/app", + "/private/var/folders/xx/App.app/Contents/MacOS/app", + "/Volumes/Installer/App.app/Contents/MacOS/app", + ] { + assert!( + is_temporary_bundle_path(Path::new(path), temp_dir), + "{path}" + ); + } + for path in [ + "/Applications/OpenCodex.app/Contents/MacOS/app", + "/Users/alice/Applications/OpenCodex.app/Contents/MacOS/app", + "/tmp-old/OpenCodex.app/Contents/MacOS/app", + "/private/var/folder-old/OpenCodex.app/Contents/MacOS/app", + ] { + assert!( + !is_temporary_bundle_path(Path::new(path), temp_dir), + "{path}" + ); + } + assert!(is_temporary_bundle_path( + Path::new("/var/folders/test/app/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + )); + assert!(has_app_translocation_marker(Path::new( + "/Volumes/Installer/AppTranslocation/OpenCodex.app/Contents/MacOS/app" + ))); + assert!(!has_app_translocation_marker(Path::new( + "/Volumes/Installer/AppTranslocation-old/OpenCodex.app/Contents/MacOS/app" + ))); + } + + #[test] + fn environment_temp_dir_must_prove_transience() { + // An ordinary ancestor named by a custom TMPDIR must not misclassify a stable + // install; "/" resolves but is not a shared sticky directory, and a missing + // path cannot be verified as temporary at all. + for temp_dir in ["/", "/definitely/absent/ocx-tmpdir"] { + let temp_dir = Path::new(temp_dir); + assert!( + !is_temporary_bundle_path( + Path::new("/Applications/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + ), + "{temp_dir:?}" + ); + assert!( + !is_temporary_bundle_path( + Path::new("/Users/alice/Applications/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + ), + "{temp_dir:?}" + ); + } + // The recognized temporary roots still prove transience on their own. + let temp_dir = Path::new("/var/folders/test/app"); + assert!(is_temporary_bundle_path( + Path::new("/var/folders/test/app/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + )); + } +} +// Return exact block range, including its line terminator. Reject malformed or changed markers. +fn range(bytes: &[u8], expected: &str) -> Result>> { + let s = std::str::from_utf8(bytes).map_err(|_| "rc-not-utf8")?; + let begins: Vec<_> = s.match_indices(START).map(|(n, _)| n).collect(); + let ends: Vec<_> = s.match_indices(END).map(|(n, _)| n).collect(); + if s.lines().any(|line| { + (line.contains("OpenCodex Desktop ocx PATH") + && (line.contains(">>>") || line.contains("<<<"))) + && line != START + && line != END + }) { + return Err("rc-markers-invalid".into()); + } + if begins.is_empty() && ends.is_empty() { + return Ok(None); + } + if begins.len() != 1 + || ends.len() != 1 + || ends[0] < begins[0] + || begins[0] > 0 && bytes[begins[0] - 1] != b'\n' + { + return Err("rc-markers-invalid".into()); + } + let finish = ends[0] + END.len(); + let finish = if s[finish..].starts_with("\r\n") { + finish + 2 + } else if s[finish..].starts_with('\n') { + finish + 1 + } else if finish == s.len() { + finish + } else { + return Err("rc-markers-invalid".into()); + }; + let candidate = &s[begins[0]..finish]; + if candidate != expected + && candidate.trim_end_matches(['\r', '\n']) != expected.trim_end_matches(['\r', '\n']) + { + return Err("rc-block-modified".into()); + } + Ok(Some(begins[0]..finish)) +} +fn newline(bytes: &[u8]) -> Result<&'static str> { + let s = std::str::from_utf8(bytes).map_err(|_| "rc-not-utf8")?; + if s.contains('\r') && (!s.contains("\r\n") || s.replace("\r\n", "").contains(['\r', '\n'])) { + return Err("rc-mixed-newlines".into()); + } + Ok(if s.contains("\r\n") { "\r\n" } else { "\n" }) +} +pub fn edit_rc(bytes: &[u8], managed: &str, remove: bool) -> Result> { + let nl = newline(bytes)?; + let expected = managed.replace('\n', nl); + let found = range(bytes, &expected)?; + if let Some(r) = &found { + if !remove && bytes[r.end..].iter().all(u8::is_ascii_whitespace) { + return Ok(bytes.to_vec()); + } + } + let mut out = bytes.to_vec(); + if let Some(r) = found { + out.drain(r); + } + if remove { + return Ok(out); + } + // Outside bytes and trailing whitespace stay in the same order; block is last nonblank content. + if !out.is_empty() && !out.ends_with(b"\n") { + out.extend_from_slice(nl.as_bytes()); + } + out.extend_from_slice(expected.as_bytes()); + Ok(out) +} +fn mode(path: &Path) -> u32 { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path) + .map(|m| m.permissions().mode() & 0o777) + .unwrap_or(0o600) + } + #[cfg(not(unix))] + { + let _ = path; + 0o600 + } +} +fn change( + store: &Store, + p: &Path, + before: Option>, + after: Option>, + mode: u32, +) -> Change { + let backup = before.as_ref().map(|_| { + store + .root + .join("backups") + .join(uuid::Uuid::new_v4().to_string()) + .to_string_lossy() + .into_owned() + }); + Change { + kind: "file".into(), + path: p.to_string_lossy().into_owned(), + before, + after, + mode, + backup_path: backup, + } +} +fn ensure_rc_parent(p: &Path, home: &Path) -> Result<()> { + let parent = p.parent().ok_or("unsafe-file")?; + if parent == home.join(".config/fish") { + for directory in [home.join(".config"), home.join(".config/fish")] { + match fs::symlink_metadata(&directory) { + Ok(_) => record::check(&directory, true)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + record::private_dir(&directory)? + } + Err(_) => return Err("io-failed".into()), + } + } + } + record::check(parent, true) +} +pub(crate) fn plan( + store: &Store, + current: &Record, + bundle: Bundle, + home: &Path, + selected: Vec<(String, PathBuf)>, +) -> Result<(Record, Vec, Vec)> { + let mut next = current.clone(); + next.bundle = Some(bundle.clone()); + let mut changes = Vec::new(); + let mut issues = Vec::new(); + let mut owned = Posix { + bin_directory: text(&store.root.join("bin"))?, + files: Vec::new(), + rc_files: Vec::new(), + }; + for (kind, p, rendered, permissions) in [ + ( + "shim", + store.root.join("bin/ocx"), + render_shim(Path::new(&bundle.cli_executable), ¤t.owner_id)?, + 0o700, + ), + ( + "path-helper", + store.root.join("path.sh"), + render_helper(&store.root.join("bin"))?, + 0o600, + ), + ] { + let old = record::read_bytes(&p)?; + let previous = current + .posix + .as_ref() + .and_then(|v| v.files.iter().find(|f| f.path == p.to_string_lossy())); + if let Some(b) = &old { + if previous.is_none_or(|f| record::hash(b) != f.sha256) { + return Err("owned-file-conflict".into()); + } + if mode(&p) != permissions { + return Err("owned-file-permissions".into()); + } + } + if old.as_deref() != Some(rendered.as_bytes()) { + changes.push(change( + store, + &p, + old.clone(), + Some(rendered.as_bytes().to_vec()), + permissions, + )); + } + owned.files.push(OwnedFile { + kind: kind.into(), + path: text(&p)?, + sha256: record::hash(rendered.as_bytes()), + created: previous.map_or(old.is_none(), |f| f.created), + }); + } + if selected.iter().filter(|(s, _)| s == "bash").count() == 1 { + issues.push("login-file-absent".into()); + } + for (shell, p) in &selected { + // Never create an arbitrary external ZDOTDIR/XDG directory tree; absent parents are reported. + let attempt: Result = (|| { + ensure_rc_parent(p, home)?; + record::rc_acl_check(p)?; + let old = record::read_bytes(p)?; + let previous = current + .posix + .as_ref() + .and_then(|v| v.rc_files.iter().find(|f| f.path == p.to_string_lossy())); + let managed = block(shell, &store.root)?; + let nl = newline(old.as_deref().unwrap_or_default())?; + let rendered = managed.replace('\n', nl); + let found = range(old.as_deref().unwrap_or_default(), &rendered).map_err(|e| { + if previous.is_some() && e == "rc-block-modified" { + "owned-block-modified".into() + } else { + e + } + })?; + if found.is_some() + && previous.is_none_or(|f| f.block_sha256 != record::hash(rendered.as_bytes())) + { + return Err(if previous.is_some() { + "owned-block-modified" + } else { + "rc-block-unowned" + } + .into()); + } + let after = edit_rc(old.as_deref().unwrap_or_default(), &managed, false)?; + let mut backup = previous.and_then(|f| f.backup_path.clone()); + if old.as_deref() != Some(after.as_slice()) { + record::rc_acl_check(p)?; + let c = change(store, p, old.clone(), Some(after), mode(p)); + if backup.is_none() { + backup = c.backup_path.clone(); + } + changes.push(c); + } + Ok(RcFile { + shell: shell.clone(), + path: text(p)?, + block_sha256: record::hash(rendered.as_bytes()), + created: previous.map_or(old.is_none(), |f| f.created), + backup_path: backup, + result: "installed".into(), + }) + })(); + match attempt { + Ok(f) => owned.rc_files.push(f), + Err(e) => { + issues.push(e); + // Retain old ownership so a refused file stays recoverable/removable after user repair. + if let Some(f) = current + .posix + .as_ref() + .and_then(|v| v.rc_files.iter().find(|f| f.path == p.to_string_lossy())) + { + owned.rc_files.push(f.clone()); + } + } + } + } + // Ownership is independent of today's ZDOTDIR, XDG_CONFIG_HOME and bash login choice. + if let Some(previous) = ¤t.posix { + for f in &previous.rc_files { + if selected.iter().any(|(_, p)| p == Path::new(&f.path)) { + continue; + } + if let Err(e) = remove_rc(store, f, &mut changes) { + issues.push(e); + owned.rc_files.push(f.clone()); + } + } + } + next.posix = Some(owned); + next.windows = None; + Ok((next, changes, issues)) +} +fn remove_rc(store: &Store, f: &RcFile, changes: &mut Vec) -> Result<()> { + let p = Path::new(&f.path); + record::rc_acl_check(p)?; + let Some(old) = record::read_bytes(p)? else { + return Ok(()); + }; + let managed = block(&f.shell, &store.root)?; + let expected = managed.replace('\n', newline(&old)?); + let found = range(&old, &expected).map_err(|e| { + if e == "rc-block-modified" { + "owned-block-modified".into() + } else { + e + } + })?; + if let Some(r) = found { + if record::hash(&old[r]) != f.block_sha256 { + return Err("owned-block-modified".into()); + } + } else { + return Ok(()); + } + let after = edit_rc(&old, &managed, true)?; + let result = if f.created && after.iter().all(u8::is_ascii_whitespace) { + None + } else { + Some(after) + }; + if result.as_deref() != Some(old.as_slice()) { + record::rc_acl_check(p)?; + changes.push(change(store, p, Some(old), result, mode(p))); + } + Ok(()) +} +pub fn remove_plan(store: &Store, current: &Record) -> Result<(Record, Vec, Vec)> { + let mut next = current.clone(); + let mut changes = Vec::new(); + let mut issues = Vec::new(); + let Some(mut owned) = current.posix.clone() else { + return Ok((next, changes, issues)); + }; + owned.rc_files.retain(|f| { + let attempt = remove_rc(store, f, &mut changes); + if let Err(e) = attempt { + issues.push(e); + true + } else { + false + } + }); + // If a refused rc still references helper/bin, keep both until repair/removal can finish. + if owned.rc_files.is_empty() { + owned.files.retain(|f| { + let attempt: Result<()> = (|| { + let p = Path::new(&f.path); + let Some(old) = record::read_bytes(p)? else { + return Ok(()); + }; + if record::hash(&old) != f.sha256 { + return Err("owned-file-conflict".into()); + } + if mode(p) != if f.kind == "shim" { 0o700 } else { 0o600 } { + return Err("owned-file-permissions".into()); + } + changes.push(change(store, p, Some(old), None, mode(p))); + Ok(()) + })(); + if let Err(e) = attempt { + issues.push(e); + true + } else { + false + } + }); + } + next.posix = (!owned.files.is_empty() || !owned.rc_files.is_empty()).then_some(owned); + Ok((next, changes, issues)) +} +#[cfg(all(test, unix))] +mod tests { + use super::*; + use record::tests::{bundle, Temp}; + #[cfg(target_os = "macos")] + fn stable_bundle_fixture() -> Temp { + // Stable bundles cannot live under the OS temp directory. Keep the fixture + // in this checkout, with the production raw/canonical checks still enabled. + let base = fs::canonicalize(env!("CARGO_MANIFEST_DIR")) + .unwrap() + .join(".tmp"); + assert!( + !is_temporary_bundle_path(&base, &std::env::temp_dir()) + && !has_app_translocation_marker(&base), + "stable bundle tests require a checkout outside temporary/translocated locations" + ); + fs::create_dir_all(&base).unwrap(); + record::check(&base, true).unwrap(); + let root = base.join(format!("ocx-stable-bundle-{}", uuid::Uuid::new_v4())); + record::private_dir(&root).unwrap(); + Temp(root) + } + #[cfg(target_os = "macos")] + fn rc_acl(path: &Path, add: bool) { + let mut command = std::process::Command::new("/bin/chmod"); + if add { + command.args(["+a", "nobody deny read"]); + } else { + command.arg("-N"); + } + assert!( + command + .arg(path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap() + .success(), + "acl-command-failed" + ); + } + #[cfg(target_os = "macos")] + #[test] + fn rc_acl_refuses_install_reposition_remove_and_keeps_owned_artifacts() { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + record::atomic(&p, None, b"# user\n", 0o600).unwrap(); + rc_acl(&p, true); + let mut r = Record::fresh(bundle()); + let selected = vec![("zsh".into(), p.clone())]; + let (next, changes, issues) = plan(&s, &r, bundle(), &t.0, selected.clone()).unwrap(); + assert_eq!(issues, ["rc-acl-present"]); + assert!(changes.iter().all(|c| c.path != p.to_string_lossy())); + assert_eq!(fs::read(&p).unwrap(), b"# user\n"); + assert!(record::rc_acl_check(&p).is_err()); + s.transact(&mut r, next, changes, "install").unwrap(); + rc_acl(&p, false); + let (next, changes, _) = plan(&s, &r, bundle(), &t.0, selected.clone()).unwrap(); + s.transact(&mut r, next, changes, "install").unwrap(); + let mut bytes = fs::read(&p).unwrap(); + bytes.extend_from_slice(b"# later\n"); + fs::write(&p, &bytes).unwrap(); + rc_acl(&p, true); + let (next, changes, issues) = plan(&s, &r, bundle(), &t.0, selected).unwrap(); + assert_eq!(issues, ["rc-acl-present"]); + assert!(changes.is_empty()); + assert_eq!(next.posix.as_ref().unwrap().rc_files.len(), 1); + r.enabled = false; + s.save(&r).unwrap(); + let (next, changes, issues) = remove_plan(&s, &r).unwrap(); + assert_eq!(issues, ["rc-acl-present"]); + assert!(changes.is_empty()); + s.transact(&mut r, next, changes, "remove").unwrap(); + assert!(r.posix.as_ref().unwrap().rc_files.len() == 1); + assert!(s.root.join("bin/ocx").is_file()); + assert!(s.root.join("path.sh").is_file()); + assert!(fs::read(&p).unwrap() == bytes); + assert!(record::rc_acl_check(&p).is_err()); + rc_acl(&p, false); + let (next, changes, issues) = remove_plan(&s, &r).unwrap(); + assert!(issues.is_empty()); + s.transact(&mut r, next, changes, "remove").unwrap(); + assert_eq!(fs::read(p).unwrap(), b"# user\n# later\n"); + assert!(r.posix.is_none()); + } + fn root() -> PathBuf { + fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!("ocx-posix-{}", uuid::Uuid::new_v4())) + } + #[test] + fn rc_edit_is_idempotent_and_repositions_after_later_npm_prepend() { + let t = root(); + record::private_dir(&t).unwrap(); + let p = t.join(".zshrc"); + let b = block("zsh", &t).unwrap(); + let first = edit_rc(b"export PATH=/npm:$PATH\n", &b, false).unwrap(); + record::atomic(&p, None, &first, 0o600).unwrap(); + assert_eq!(edit_rc(&first, &b, false).unwrap(), first); + let later = [first.as_slice(), b"export PATH=/later:$PATH\n"].concat(); + let repaired = edit_rc(&later, &b, false).unwrap(); + assert!(repaired.ends_with(b.as_bytes())); + assert_eq!( + edit_rc(&repaired, &b, true).unwrap(), + b"export PATH=/npm:$PATH\nexport PATH=/later:$PATH\n" + ); + fs::remove_dir_all(t).unwrap(); + } + #[test] + fn broken_duplicate_nested_or_modified_blocks_are_refused() { + let b = block("bash", Path::new("/example/record")).unwrap(); + for bytes in [ + START.to_owned(), + END.to_owned(), + format!("{b}{b}"), + format!("{START}\n{START}\n{END}\n{END}\n"), + b.replace("if [ -r", "if [ -w"), + ] { + assert!(edit_rc(bytes.as_bytes(), &b, false).is_err()); + assert!(edit_rc(bytes.as_bytes(), &b, true).is_err()); + } + } + #[test] + fn rc_newlines_and_outside_bytes_survive_removal() { + let b = block("zsh", Path::new("/example/record")).unwrap(); + let original = b"# user's config\r\nexport PATH=\"/npm:$PATH\"\r\n"; + let installed = edit_rc(original, &b, false).unwrap(); + assert_eq!(edit_rc(&installed, &b, true).unwrap(), original); + assert!(edit_rc(b"a\r\nb\n", &b, false).is_err()); + assert!(edit_rc(&[0xff], &b, false).is_err()); + } + #[test] + fn quoting_and_invalid_path_contract_is_exact() { + assert_eq!( + quote(Path::new("/example/a'b")).unwrap(), + "'/example/a'\"'\"'b'" + ); + for s in ["relative", "/example/a:b", "/example/a\nb"] { + assert!(quote(Path::new(s)).is_err()); + } + } + #[test] + fn development_and_temporary_launches_are_refused_before_installation() { + assert_eq!( + stable_bundle(Path::new("/example/app"), true, "1").unwrap_err(), + "development-launch" + ); + for p in [ + "/Volumes/DMG/App.app/Contents/MacOS/app", + "/private/var/folders/a/AppTranslocation/b/App.app/Contents/MacOS/app", + "/tmp/.mount_example/usr/bin/app", + ] { + assert_eq!( + stable_bundle(Path::new(p), false, "1").unwrap_err(), + "temporary-bundle" + ); + } + } + #[test] + fn owned_and_desired_rc_paths_reconcile_without_losing_modified_ownership() { + let t = Temp::new(); + let old = t.0.join("old"); + let new = t.0.join("new"); + record::private_dir(&old).unwrap(); + record::private_dir(&new).unwrap(); + let s = Store::open(t.0.join("record"), vec![new.join(".zshrc")]).unwrap(); + let mut r = Record::fresh(bundle()); + let (next, changes, issues) = plan( + &s, + &r, + bundle(), + &t.0, + vec![("zsh".into(), old.join(".zshrc"))], + ) + .unwrap(); + assert!(issues.is_empty()); + s.transact(&mut r, next, changes, "install").unwrap(); + let old_rc = old.join(".zshrc"); + fs::write( + &old_rc, + fs::read_to_string(&old_rc) + .unwrap() + .replace("if [ -r", "if [ -w"), + ) + .unwrap(); + let modified = fs::read(&old_rc).unwrap(); + let (next, changes, issues) = plan( + &s, + &r, + bundle(), + &t.0, + vec![("zsh".into(), new.join(".zshrc"))], + ) + .unwrap(); + assert_eq!(issues, ["owned-block-modified"]); + assert_eq!(next.posix.as_ref().unwrap().rc_files.len(), 2); + s.transact(&mut r, next, changes, "install").unwrap(); + assert_eq!(fs::read(&old_rc).unwrap(), modified); + // Repair the old block, then retire it although today's target selector no longer lists it. + fs::write(&old_rc, block("zsh", &s.root).unwrap()).unwrap(); + let (next, changes, issues) = plan( + &s, + &r, + bundle(), + &t.0, + vec![("zsh".into(), new.join(".zshrc"))], + ) + .unwrap(); + assert!(issues.is_empty()); + s.transact(&mut r, next, changes, "install").unwrap(); + assert!(!old_rc.exists()); + assert_eq!(r.posix.as_ref().unwrap().rc_files.len(), 1); + } + #[test] + fn modified_rc_keeps_helper_until_cleanup_and_preserves_user_content() { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + record::atomic(&p, None, b"# user\n", 0o600).unwrap(); + let mut r = Record::fresh(bundle()); + let (next, changes, _) = + plan(&s, &r, bundle(), &t.0, vec![("zsh".into(), p.clone())]).unwrap(); + s.transact(&mut r, next, changes, "install").unwrap(); + fs::write( + &p, + fs::read_to_string(&p) + .unwrap() + .replace("if [ -r", "if [ -w"), + ) + .unwrap(); + let (_, changes, issues) = remove_plan(&s, &r).unwrap(); + assert_eq!(issues, ["owned-block-modified"]); + assert!(changes.is_empty()); + assert!(s.root.join("path.sh").exists()); + fs::write( + &p, + format!("# user\n{}# later\n", block("zsh", &s.root).unwrap()), + ) + .unwrap(); + r.enabled = false; + s.save(&r).unwrap(); + let (next, changes, issues) = remove_plan(&s, &r).unwrap(); + assert!(issues.is_empty()); + s.transact(&mut r, next, changes, "remove").unwrap(); + assert_eq!(fs::read(p).unwrap(), b"# user\n# later\n"); + assert!(!s.root.join("path.sh").exists()); + assert!(r.posix.is_none()); + } + #[test] + fn unrecorded_block_and_generated_file_are_not_adopted() { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + let r = Record::fresh(bundle()); + record::atomic(&p, None, block("zsh", &s.root).unwrap().as_bytes(), 0o600).unwrap(); + let (next, _, issues) = plan(&s, &r, bundle(), &t.0, vec![("zsh".into(), p)]).unwrap(); + assert_eq!(issues, ["rc-block-unowned"]); + assert!(next.posix.unwrap().rc_files.is_empty()); + record::atomic(&s.root.join("bin/ocx"), None, b"user", 0o700).unwrap(); + assert_eq!( + plan(&s, &r, bundle(), &t.0, vec![]).unwrap_err(), + "owned-file-conflict" + ); + } + #[test] + fn zdotdir_xdg_and_bash_login_selection_are_injected() { + let t = Temp::new(); + let chosen = targets_in(&t.0, Some(t.0.join("zdot")), Some(t.0.join("xdg"))).unwrap(); + assert_eq!(chosen[0].1, t.0.join("zdot/.zshrc")); + assert_eq!(chosen[3].1, t.0.join("xdg/fish/config.fish")); + assert_eq!(chosen.len(), 4); + record::atomic(&t.0.join(".profile"), None, b"# fallback", 0o600).unwrap(); + record::atomic(&t.0.join(".bash_login"), None, b"# active", 0o600).unwrap(); + let chosen = targets_in(&t.0, Some("relative".into()), None).unwrap(); + assert_eq!(chosen[0].1, t.0.join(".zshrc")); + assert_eq!(chosen[4].1, t.0.join(".bash_login")); + } + #[test] + fn path_helper_preserves_empty_entries_and_prepends_once() { + let bin = Path::new("/example/desktop/bin"); + let helper = render_helper(bin).unwrap(); + for (path, expected) in [ + (None, "/example/desktop/bin"), + (Some(""), "/example/desktop/bin:"), + ( + Some(":/npm::/example/desktop/bin:/other:"), + "/example/desktop/bin::/npm::/other:", + ), + ] { + let mut command = std::process::Command::new("/bin/sh"); + let unset = if path.is_none() { "unset PATH\n" } else { "" }; + command + .env_clear() + .arg("-c") + .arg(format!("{unset}{helper}\nprintf '%s' \"$PATH\"")); + if let Some(path) = path { + command.env("PATH", path); + } + let output = command.output().unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8(output.stdout).unwrap(), expected); + } + } + #[test] + fn shim_generates_argv_bound_proof_and_fails_closed_when_bundle_is_missing() { + let t = Temp::new(); + let cli = t.0.join("cli"); + let shim = t.0.join("shim"); + let inspect = b"#!/bin/sh\nprintf '%s\\n' \"$OCX_NODE_LAUNCH_CONTEXT\" \"$@\"\n"; + record::atomic(&cli, None, inspect, 0o700).unwrap(); + record::atomic( + &shim, + None, + render_shim(&cli, "owner").unwrap().as_bytes(), + 0o700, + ) + .unwrap(); + let out = std::process::Command::new(&shim) + .env_clear() + .env("ANTHROPIC_AUTH_TOKEN", "dummy") + .env("ANTHROPIC_API_KEY", "") + .env("OCX_NODE_LAUNCH_CONTEXT", "untrusted") + .arg("status") + .output() + .unwrap(); + assert!(out.status.success()); + let text = String::from_utf8(out.stdout).unwrap(); + let lines: Vec<_> = text.lines().collect(); + let context: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + let proof = context["proof"].as_str().unwrap(); + assert_eq!(proof.len(), 43); + assert_eq!(lines[1], format!("--ocx-internal-launch-proof={proof}")); + assert_eq!( + context["anthropicEnvSlots"], + serde_json::json!(["ANTHROPIC_AUTH_TOKEN"]) + ); + assert!(context["codexCliInspectionEnv"].is_null()); + assert!(!text.contains("dummy")); + fs::remove_file(cli).unwrap(); + assert_eq!( + std::process::Command::new(&shim) + .env_clear() + .output() + .unwrap() + .status + .code(), + Some(127) + ); + // Exercise unavailable/malformed utility fallback without changing the machine's utilities. + for rendered in [ + render_shim(&t.0.join("cli"), "owner") + .unwrap() + .replace("/usr/bin/od", "/no-such-od"), + render_shim(&t.0.join("cli"), "owner") + .unwrap() + .replace("/usr/bin/awk", "/no-such-awk"), + render_shim(&t.0.join("cli"), "owner") + .unwrap() + .replace("/dev/urandom", "/no-such-urandom"), + render_shim(&t.0.join("cli"), "owner") + .unwrap() + .replace("-N32", "-N1"), + ] { + record::atomic(&t.0.join("cli"), None, inspect, 0o700).unwrap(); + fs::write(&shim, rendered).unwrap(); + let out = std::process::Command::new(&shim) + .env_clear() + .env("OCX_NODE_LAUNCH_CONTEXT", "inherited") + .arg("status") + .output() + .unwrap(); + assert!(out.status.success()); + assert_eq!(out.stdout, b"\nstatus\n"); + fs::remove_file(t.0.join("cli")).unwrap(); + } + } + #[cfg(target_os = "macos")] + #[test] + fn stable_macos_bundle_requires_an_executable_sibling_and_checks_canonical_location() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let t = stable_bundle_fixture(); + let app = t.0.join("App.app/Contents/MacOS"); + fs::create_dir_all(&app).unwrap(); + let exe = app.join("app"); + let cli = app.join("ocx"); + record::atomic(&exe, None, b"app", 0o700).unwrap(); + assert_eq!( + stable_bundle(&exe, false, "1").unwrap_err(), + "bundle-cli-missing" + ); + record::atomic(&cli, None, b"cli", 0o600).unwrap(); + assert_eq!( + stable_bundle(&exe, false, "1").unwrap_err(), + "bundle-cli-not-executable" + ); + fs::set_permissions(&cli, fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + stable_bundle(&exe, false, "1").unwrap().cli_executable, + cli.to_str().unwrap() + ); + let translocated = t.0.join("AppTranslocation"); + record::private_dir(&translocated).unwrap(); + fs::rename(t.0.join("App.app"), translocated.join("App.app")).unwrap(); + symlink(translocated.join("App.app"), t.0.join("App.app")).unwrap(); + assert_eq!( + stable_bundle(&exe, false, "1").unwrap_err(), + "temporary-bundle" + ); + } + #[test] + fn missing_external_rc_parent_is_partial_and_default_fish_parents_are_created() { + let t = Temp::new(); + let s = t.store(); + let r = Record::fresh(bundle()); + let external = t.0.join("missing/config.fish"); + let (_, _, issues) = plan(&s, &r, bundle(), &t.0, vec![("fish".into(), external)]).unwrap(); + assert!(!issues.is_empty() && !t.0.join("missing").exists()); + let (next, changes, issues) = plan( + &s, + &r, + bundle(), + &t.0, + vec![("fish".into(), t.0.join(".config/fish/config.fish"))], + ) + .unwrap(); + assert!(issues.is_empty()); + let mut r = r; + s.transact(&mut r, next, changes, "install").unwrap(); + assert!(t.0.join(".config/fish/config.fish").exists()); + } + #[test] + #[ignore = "explicit wp3 compiled CLI target/output paths only"] + fn render_shim_for_compiled_cli() { + let cli = PathBuf::from(std::env::var_os("OCX_TEST_SHIM_TARGET").expect("target required")); + let output = + PathBuf::from(std::env::var_os("OCX_TEST_SHIM_OUTPUT").expect("output required")); + record::atomic( + &output, + None, + render_shim(&cli, "test-owner").unwrap().as_bytes(), + 0o700, + ) + .unwrap(); + } + #[test] + #[ignore = "explicit real-shell evidence using an isolated temporary HOME"] + fn real_shell_selects_desktop_shim_on_temp_home() { + use std::{io::Write, process::Command}; + + let t = Temp::new(); + let npm = t.0.join("npm-bin"); + record::private_dir(&npm).unwrap(); + record::atomic(&npm.join("ocx"), None, b"#!/bin/sh\necho npm-ocx\n", 0o700).unwrap(); + #[cfg(target_os = "macos")] + let stable = stable_bundle_fixture(); + #[cfg(target_os = "macos")] + let app = stable.0.join("Applications/OpenCodex.app/Contents/MacOS"); + #[cfg(not(target_os = "macos"))] + let app = t.0.join("usr/bin"); + fs::create_dir_all(&app).unwrap(); + let exe = app.join("OpenCodex"); + let cli = app.join("ocx"); + record::atomic(&exe, None, b"#!/bin/sh\nexit 0\n", 0o700).unwrap(); + // The real CLI consumes the shim's private proof argument before dispatch. + record::atomic( + &cli, + None, + b"#!/bin/sh\ncase ${1-} in --ocx-internal-launch-proof=*) shift ;; esac\necho desktop-ocx \"$@\"\n", + 0o700, + ) + .unwrap(); + #[cfg(target_os = "macos")] + let installed_bundle = stable_bundle(&exe, false, "1").unwrap(); + #[cfg(not(target_os = "macos"))] + let installed_bundle = Bundle { + app_executable: text(&exe).unwrap(), + cli_executable: text(&cli).unwrap(), + ..bundle() + }; + + let prepend = format!("export PATH=\"{}:$PATH\"\n", npm.display()); + let bash_profile = format!("{prepend}. \"$HOME/.bashrc\"\n"); + for (name, contents) in [ + (".zshrc", prepend.as_str()), + (".bashrc", prepend.as_str()), + (".bash_profile", bash_profile.as_str()), + ] { + record::atomic(&t.0.join(name), None, contents.as_bytes(), 0o600).unwrap(); + } + // Inject selectors instead of consulting the developer's ZDOTDIR/XDG settings. + let selected = targets_in(&t.0, None, None).unwrap(); + let s = Store::open( + t.0.join("record"), + selected.iter().map(|(_, path)| path.clone()).collect(), + ) + .unwrap(); + let mut r = Record::fresh(installed_bundle.clone()); + let shim = s.root.join("bin/ocx"); + let shells: Vec<_> = ["zsh", "bash"] + .into_iter() + .filter_map(|shell| { + let executable = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"] + .into_iter() + .map(|directory| Path::new(directory).join(shell)) + .find(|path| path.is_file()); + if executable.is_none() { + eprintln!("{shell}: skipped (shell unavailable)"); + } + executable.map(|executable| (shell, executable)) + }) + .collect(); + let check_shells = |expected_path: &Path, expected_output: &str| { + for (shell, executable) in &shells { + let mut command = Command::new(executable); + command + .env_clear() + .env("HOME", &t.0) + .env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + .env("TERM", "dumb") + .current_dir(&t.0); + if *shell == "bash" { + command.arg("-l"); + } + // System rc files may print to stdout (Ubuntu's /etc/bash.bashrc sudo hint), so only + // the marked result lines count; a failed lookup or CLI run still fails the shell, and + // the end marker makes any extra CLI output line fail the comparison. + let output = command + .args([ + "-i", + "-c", + "p=$(command -v ocx) || exit 11; o=$(ocx hello) || exit 12; \ + printf 'OCX-PATH:%s\\nOCX-OUT:%s:OCX-END\\n' \"$p\" \"$o\"", + ]) + .output() + .expect("cannot start real shell"); + assert!(output.status.success(), "{shell}: command failed"); + let stdout = String::from_utf8(output.stdout).expect("shell stdout is not UTF-8"); + let marked = |tag: &str| { + stdout + .lines() + .filter_map(|line| line.strip_prefix(tag)) + .collect::>() + }; + let (paths, outputs) = (marked("OCX-PATH:"), marked("OCX-OUT:")); + // Keep captured system-rc output and temporary HOME paths out of failure logs. + assert!( + paths.len() == 1 && outputs.len() == 1, + "{shell}: expected exactly one marked path and one marked output line" + ); + assert!( + paths[0] == expected_path.to_str().unwrap(), + "{shell}: command resolution selected the wrong executable" + ); + assert!( + outputs[0] == format!("{expected_output}:OCX-END"), + "{shell}: wrong CLI output" + ); + } + }; + check_shells(&npm.join("ocx"), "npm-ocx"); + for reconcile in [false, true] { + if reconcile { + for name in [".zshrc", ".bashrc"] { + let p = t.0.join(name); + assert!(fs::read_to_string(&p) + .unwrap() + .ends_with(&format!("{END}\n"))); + fs::OpenOptions::new() + .append(true) + .open(&p) + .unwrap() + .write_all(prepend.as_bytes()) + .unwrap(); + assert!(fs::read_to_string(&p).unwrap().ends_with(&prepend)); + } + } + let (next, changes, issues) = + plan(&s, &r, installed_bundle.clone(), &t.0, selected.clone()).unwrap(); + assert!(issues.is_empty(), "installation reported issues"); + s.transact(&mut r, next, changes, "install").unwrap(); + assert!(shim.is_file() && s.root.join("path.sh").is_file()); + for name in [".zshrc", ".bashrc", ".bash_profile"] { + let contents = fs::read_to_string(t.0.join(name)).unwrap(); + assert!(contents.ends_with(&format!("{END}\n"))); + assert_eq!(contents.matches(START).count(), 1); + } + check_shells(&shim, "desktop-ocx hello"); + } + r.enabled = false; + s.save(&r).unwrap(); + let (next, changes, issues) = remove_plan(&s, &r).unwrap(); + assert!(issues.is_empty(), "removal reported issues"); + s.transact(&mut r, next, changes, "remove").unwrap(); + assert!(r.posix.is_none()); + assert!(!shim.exists() && !s.root.join("path.sh").exists()); + for (name, expected) in [ + (".zshrc", prepend.repeat(2)), + (".bashrc", prepend.repeat(2)), + (".bash_profile", bash_profile), + ] { + assert!( + fs::read_to_string(t.0.join(name)).unwrap() == expected, + "user rc contents were changed during removal" + ); + } + check_shells(&npm.join("ocx"), "npm-ocx"); + } +} diff --git a/desktop/src-tauri/src/cli_command_record.rs b/desktop/src-tauri/src/cli_command_record.rs new file mode 100644 index 00000000000..294c0d86346 --- /dev/null +++ b/desktop/src-tauri/src/cli_command_record.rs @@ -0,0 +1,1997 @@ +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use uuid::Uuid; + +pub type Result = std::result::Result; +pub fn hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn io(value: std::io::Result) -> Result { + value.map_err(|_| "io-failed".into()) +} +fn digest(s: &str) -> bool { + s.len() == 64 + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +pub const RECORD_LIMIT: usize = 64 * 1024; +const JOURNAL_LIMIT: usize = 8 * 1024 * 1024; +pub fn platform() -> &'static str { + if cfg!(windows) { + "win32" + } else if cfg!(target_os = "macos") { + "darwin" + } else { + "linux" + } +} +fn absolute_for(s: &str, host: &str) -> bool { + if s.chars().count() > 4096 || s.contains(['\0', '\n', '\r']) { + return false; + } + let absolute = if host == "win32" { + let b = s.as_bytes(); + (b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\')) + || s.starts_with("\\\\") + || s.starts_with("//") + } else { + s.starts_with('/') + }; + absolute + && !s + .split(if host == "win32" { + &['/', '\\'][..] + } else { + &['/'][..] + }) + .any(|part| matches!(part, "." | "..")) +} +fn absolute(s: &str) -> bool { + absolute_for(s, platform()) +} +fn bundle_valid(b: &Bundle, host: &str) -> bool { + b.platform == host + && absolute_for(&b.app_executable, host) + && absolute_for(&b.cli_executable, host) + && !b.version.is_empty() + && matches!( + (host, b.kind.as_str()), + ("darwin", "macos-app") | ("win32", "windows-install") | ("linux", "linux-deb") + ) +} +fn fingerprint(bytes: Option<&[u8]>) -> String { + bytes.map(hash).unwrap_or_else(|| "absent".into()) +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Bundle { + pub platform: String, + pub app_executable: String, + pub cli_executable: String, + pub version: String, + pub kind: String, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OwnedFile { + pub kind: String, + pub path: String, + pub sha256: String, + pub created: bool, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RcFile { + pub shell: String, + pub path: String, + pub block_sha256: String, + pub created: bool, + pub backup_path: Option, + pub result: String, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Posix { + pub bin_directory: String, + pub files: Vec, + pub rc_files: Vec, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Windows { + pub key: String, + pub value: String, + pub entry: String, + pub value_type: String, + pub action: String, + pub previous_before: Option, + pub previous_after: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Change { + pub kind: String, + pub path: String, + pub before: Option>, + pub after: Option>, + pub mode: u32, + pub backup_path: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PendingChange { + pub kind: String, + pub path: String, + pub mode: u32, + pub backup_path: Option, + pub journal_file: String, + pub before_sha256: String, + pub after_sha256: String, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Journal { + pub operation: String, + pub changes: Vec, + pub next: Box, +} +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct JournalFile { + version: u32, + kind: String, + path: String, + mode: Option, + before: Option, + after: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Record { + pub version: u32, + pub owner_id: String, + pub install_id: String, + pub generation: u64, + pub enabled: bool, + pub bundle: Option, + pub posix: Option, + pub windows: Option, + #[serde(default)] + pub notify_pending: bool, + pub pending: Option, +} +impl Record { + pub fn fresh(bundle: Bundle) -> Self { + let install_id = Uuid::new_v4().to_string(); + Self { + version: 1, + owner_id: Uuid::new_v4().to_string(), + install_id, + generation: 1, + enabled: true, + bundle: Some(bundle), + posix: None, + windows: None, + notify_pending: false, + pending: None, + } + } +} +// Desired targets authorize new writes; persisted rc ownership survives environment changes. +pub struct Store { + pub root: PathBuf, + pub rc_allowed: Vec, + _lock: LockGuard, +} +struct LockGuard { + file: fs::File, +} +impl LockGuard { + fn unlock(&self) -> i32 { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // LOCK_UN releases the shared file description even if a fork retains a duplicate. + unsafe { flock(self.file.as_raw_fd(), 8) } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + let mut v = Overlapped::zero(); + unsafe { UnlockFileEx(self.file.as_raw_handle(), 0, 1, 0, &mut v) } + } + } + #[cfg(test)] + fn release_for_test(&self) -> i32 { + self.unlock() + } +} +impl Drop for LockGuard { + fn drop(&mut self) { + let _ = self.unlock(); + } +} +#[cfg(windows)] +#[repr(C)] +struct Overlapped { + internal: usize, + high: usize, + offset: u32, + offset_high: u32, + event: *mut std::ffi::c_void, +} +#[cfg(windows)] +impl Overlapped { + fn zero() -> Self { + Self { + internal: 0, + high: 0, + offset: 0, + offset_high: 0, + event: std::ptr::null_mut(), + } + } +} +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + fn LockFileEx( + h: *mut std::ffi::c_void, + flags: u32, + reserved: u32, + low: u32, + high: u32, + overlapped: *mut Overlapped, + ) -> i32; + fn UnlockFileEx( + h: *mut std::ffi::c_void, + reserved: u32, + low: u32, + high: u32, + overlapped: *mut Overlapped, + ) -> i32; +} +#[cfg(unix)] +unsafe extern "C" { + fn geteuid() -> u32; + fn flock(fd: i32, op: i32) -> i32; +} +#[cfg(target_os = "macos")] +mod acl { + use super::*; + use std::{ + ffi::{c_void, CString}, + os::unix::ffi::OsStrExt, + }; + const EXTENDED: i32 = 0x100; + unsafe extern "C" { + fn acl_get_file(path: *const i8, kind: i32) -> *mut c_void; + fn acl_get_entry(acl: *mut c_void, entry_id: i32, entry: *mut *mut c_void) -> i32; + fn acl_set_file(path: *const i8, kind: i32, acl: *mut c_void) -> i32; + fn acl_init(count: i32) -> *mut c_void; + fn acl_free(acl: *mut c_void) -> i32; + } + fn name(path: &Path) -> Result { + CString::new(path.as_os_str().as_bytes()).map_err(|_| "private-acl-unsafe".into()) + } + pub fn present(path: &Path) -> Result { + // NULL/ENOENT denotes no ACL only on an existing, non-symlink path. + let m = fs::symlink_metadata(path).map_err(|_| "private-acl-unsafe")?; + if m.file_type().is_symlink() { + return Err("private-acl-unsafe".into()); + } + let path = name(path)?; + let acl = unsafe { acl_get_file(path.as_ptr(), EXTENDED) }; + if acl.is_null() { + return if std::io::Error::last_os_error().raw_os_error() == Some(2) { + Ok(false) + } else { + Err("private-acl-unsafe".into()) + }; + } + let mut entry = std::ptr::null_mut(); + // Darwin returns 0 for an entry and -1/EINVAL for an empty ACL. + let result = unsafe { acl_get_entry(acl, 0, &mut entry) }; + let errno = std::io::Error::last_os_error().raw_os_error(); + let freed = unsafe { acl_free(acl) }; + if freed != 0 { + return Err("private-acl-unsafe".into()); + } + match (result, errno) { + (0, _) => Ok(true), + (-1, Some(22)) => Ok(false), + _ => Err("private-acl-unsafe".into()), + } + } + pub fn clear(path: &Path) -> Result<()> { + let path = name(path)?; + let acl = unsafe { acl_init(0) }; + if acl.is_null() { + return Err("private-acl-unsafe".into()); + } + let result = unsafe { acl_set_file(path.as_ptr(), EXTENDED, acl) }; + let freed = unsafe { acl_free(acl) }; + if result != 0 || freed != 0 { + Err("private-acl-unsafe".into()) + } else { + Ok(()) + } + } +} +#[cfg(target_os = "linux")] +mod acl { + use super::*; + use std::{ + ffi::{c_void, CString}, + os::unix::ffi::OsStrExt, + }; + const NAMES: [&[u8]; 2] = [b"system.posix_acl_access\0", b"system.posix_acl_default\0"]; + unsafe extern "C" { + fn getxattr(path: *const i8, name: *const i8, value: *mut c_void, size: usize) -> isize; + fn removexattr(path: *const i8, name: *const i8) -> i32; + } + fn name(path: &Path) -> Result { + CString::new(path.as_os_str().as_bytes()).map_err(|_| "private-acl-unsafe".into()) + } + pub fn present(path: &Path) -> Result { + let m = fs::symlink_metadata(path).map_err(|_| "private-acl-unsafe")?; + if m.file_type().is_symlink() { + return Err("private-acl-unsafe".into()); + } + let path = name(path)?; + for attr in NAMES { + let n = + unsafe { getxattr(path.as_ptr(), attr.as_ptr().cast(), std::ptr::null_mut(), 0) }; + if n >= 0 { + return Ok(true); + } + if !matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(61 | 95) + ) { + return Err("private-acl-unsafe".into()); + } + } + Ok(false) + } + pub fn clear(path: &Path) -> Result<()> { + let path = name(path)?; + for attr in NAMES { + if unsafe { removexattr(path.as_ptr(), attr.as_ptr().cast()) } != 0 + && !matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(61 | 95) + ) + { + return Err("private-acl-unsafe".into()); + } + } + Ok(()) + } +} +pub fn rc_acl_check(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err("rc-acl-present".into()), + Ok(_) => {} + } + #[cfg(unix)] + if acl::present(path).map_err(|_| "rc-acl-present")? { + return Err("rc-acl-present".into()); + } + Ok(()) +} +fn private_check(path: &Path, directory: bool, mode: u32, root: bool) -> Result<()> { + check(path, directory).map_err(|_| "private-acl-unsafe")?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let m = fs::symlink_metadata(path).map_err(|_| "private-acl-unsafe")?; + if m.mode() & 0o777 != mode || acl::present(path)? { + return Err("private-acl-unsafe".into()); + } + let _ = root; + } + #[cfg(windows)] + { + let _ = mode; + crate::cli_command_windows::private_acl::verify(path, root)?; + } + Ok(()) +} +fn harden_new(path: &Path, directory: bool) -> Result<()> { + #[cfg(unix)] + { + acl::clear(path)?; + if acl::present(path)? { + return Err("private-acl-unsafe".into()); + } + let _ = directory; + } + #[cfg(windows)] + crate::cli_command_windows::private_acl::harden(path, directory)?; + Ok(()) +} +pub fn check(path: &Path, directory: bool) -> Result<()> { + let m = io(fs::symlink_metadata(path))?; + for parent in path.ancestors().skip(1) { + if fs::symlink_metadata(parent) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(true) + { + return Err("unsafe-parent".into()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if fs::symlink_metadata(parent) + .map(|m| m.file_attributes() & 0x400 != 0) + .unwrap_or(true) + { + return Err("unsafe-parent".into()); + } + } + } + if m.file_type().is_symlink() || (directory && !m.is_dir()) || (!directory && !m.is_file()) { + return Err("unsafe-file".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if m.uid() != unsafe { geteuid() } { + return Err("foreign-owner".into()); + } + if m.mode() & 0o222 == 0 { + return Err("read-only".into()); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if m.file_attributes() & 0x400 != 0 { + return Err("unsafe-file".into()); + } + } + if m.permissions().readonly() { + return Err("read-only".into()); + } + Ok(()) +} +pub fn private_dir(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(_) => private_check(path, true, 0o700, true)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + let mut b = fs::DirBuilder::new(); + b.mode(0o700); + io(b.create(path))?; + } + #[cfg(not(unix))] + io(fs::create_dir(path))?; + harden_new(path, true)?; + } + Err(_) => return Err("io-failed".into()), + } + private_check(path, true, 0o700, true) +} +fn read_limited(path: &Path, limit: usize, reason: &str) -> Result>> { + match fs::symlink_metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err("io-failed".into()), + Ok(m) if m.len() > limit as u64 => return Err(reason.into()), + Ok(_) => check(path, false)?, + } + let mut f = io(fs::File::open(path))?; + let mut out = Vec::new(); + io((&mut f).take(limit as u64 + 1).read_to_end(&mut out))?; + if out.len() > limit { + return Err(reason.into()); + } + Ok(Some(out)) +} +pub fn read_bytes(path: &Path) -> Result>> { + read_limited(path, JOURNAL_LIMIT, "rc-too-large") +} +fn private_bytes(path: &Path, limit: usize, reason: &str) -> Result>> { + let bytes = read_limited(path, limit, reason)?; + if bytes.is_some() { + private_check(path, false, 0o600, false)?; + } + Ok(bytes) +} +fn sync_dir(path: &Path) -> Result<()> { + #[cfg(unix)] + io(fs::File::open(path).and_then(|f| f.sync_all()))?; + #[cfg(not(unix))] + let _ = path; + Ok(()) +} +fn new_file(path: &Path, mode: u32) -> Result { + let mut o = fs::OpenOptions::new(); + o.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(mode); + } + #[cfg(not(unix))] + let _ = mode; + let file = io(o.open(path))?; + harden_new(path, false)?; + Ok(file) +} +#[cfg(windows)] +fn replace(from: &Path, to: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + #[link(name = "kernel32")] + unsafe extern "system" { + fn MoveFileExW(a: *const u16, b: *const u16, flags: u32) -> i32; + } + let a: Vec = from.as_os_str().encode_wide().chain(Some(0)).collect(); + let b: Vec = to.as_os_str().encode_wide().chain(Some(0)).collect(); + if unsafe { MoveFileExW(a.as_ptr(), b.as_ptr(), 0x1 | 0x8) } == 0 { + Err("rename-failed".into()) + } else { + Ok(()) + } +} +#[cfg(not(windows))] +fn replace(from: &Path, to: &Path) -> Result<()> { + io(fs::rename(from, to)) +} +pub fn atomic(path: &Path, before: Option<&[u8]>, after: &[u8], mode: u32) -> Result<()> { + let parent = path.parent().ok_or("unsafe-file")?; + check(parent, true)?; + rc_acl_check(path)?; + let tmp = parent.join(format!(".ocx-cli-{}", Uuid::new_v4())); + let result = (|| { + let original = fs::symlink_metadata(path).ok(); + let mut f = new_file(&tmp, mode)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + io(f.set_permissions(fs::Permissions::from_mode(mode)))?; + } + io(f.write_all(after))?; + io(f.sync_all())?; + let observed = fs::symlink_metadata(path).ok(); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if original + .as_ref() + .map(|m| (m.dev(), m.ino(), m.mode(), m.mtime(), m.mtime_nsec())) + != observed + .as_ref() + .map(|m| (m.dev(), m.ino(), m.mode(), m.mtime(), m.mtime_nsec())) + { + return Err("concurrent-edit".into()); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if original + .as_ref() + .map(|m| (m.creation_time(), m.last_write_time(), m.file_attributes())) + != observed + .as_ref() + .map(|m| (m.creation_time(), m.last_write_time(), m.file_attributes())) + { + return Err("concurrent-edit".into()); + } + } + rc_acl_check(path)?; + if read_bytes(path)?.as_deref() != before { + return Err("concurrent-edit".into()); + } + replace(&tmp, path)?; + #[cfg(unix)] + io(fs::File::open(parent).and_then(|f| f.sync_all()))?; + Ok(()) + })(); + let _ = fs::remove_file(tmp); + result +} +fn lock(root: &Path) -> Result { + let p = root.join("cli.lock"); + let existed = fs::symlink_metadata(&p).is_ok(); + if fs::symlink_metadata(&p).is_ok() { + check(&p, false)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if io(fs::metadata(&p))?.permissions().mode() & 0o777 != 0o600 { + return Err("lock-permissions".into()); + } + } + } + let mut o = fs::OpenOptions::new(); + o.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + let f = io(o.open(&p))?; + if !existed { + harden_new(&p, false)?; + } + private_check(&p, false, 0o600, false)?; + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // LOCK_EX | LOCK_NB: immediate exclusion; the guard unlocks before closing. + if unsafe { flock(f.as_raw_fd(), 2 | 4) } != 0 { + return Err("lock-busy".into()); + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + let mut v = Overlapped::zero(); + if unsafe { LockFileEx(f.as_raw_handle(), 1 | 2, 0, 1, 0, &mut v) } == 0 { + return Err("lock-busy".into()); + } + } + // A failed acquisition must only close its own file, never unlock another owner. + Ok(LockGuard { file: f }) +} +impl Store { + pub fn open(root: PathBuf, rc_allowed: Vec) -> Result { + // Inspect every existing private path before creating even the lock file. + if fs::symlink_metadata(&root).is_ok() { + private_check(&root, true, 0o700, true)?; + for (name, directory, mode) in [ + ("bin", true, 0o700), + ("journal", true, 0o700), + ("backups", true, 0o700), + ("cli.json", false, 0o600), + ("cli.lock", false, 0o600), + ("bin/ocx", false, 0o700), + ("path.sh", false, 0o600), + ] { + let path = root.join(name); + match fs::symlink_metadata(&path) { + Ok(_) => private_check(&path, directory, mode, false)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err("private-acl-unsafe".into()), + } + if matches!(name, "journal" | "backups") { + for entry in io(fs::read_dir(&path))? { + private_check(&io(entry)?.path(), false, 0o600, false)?; + } + } + } + } + private_dir(&root)?; + let guard = lock(&root)?; + for name in ["bin", "backups", "journal"] { + let path = root.join(name); + if fs::symlink_metadata(&path).is_ok() { + private_check(&path, true, 0o700, false)?; + } else { + private_dir(&path)?; + } + } + let store = Self { + root, + rc_allowed, + _lock: guard, + }; + let record = store.read()?; // A missing record is a validated empty record. + store.clean_unreferenced(record.as_ref())?; + Ok(store) + } + fn owned_path(&self, s: &str, r: &Record, next: &Record) -> bool { + let p = Path::new(s); + absolute(s) + && (p == self.root.join("bin/ocx") + || p == self.root.join("path.sh") + || self.rc_allowed.iter().any(|r| r == p) + || [r, next].iter().any(|r| { + r.posix + .as_ref() + .is_some_and(|v| v.rc_files.iter().any(|f| f.path == s)) + })) + } + fn backup_path(&self, s: &str) -> bool { + absolute(s) && Path::new(s).parent() == Some(self.root.join("backups").as_path()) + } + fn journal_path(&self, s: &str) -> bool { + let p = Path::new(s); + if !absolute(s) + || p.parent() != Some(self.root.join("journal").as_path()) + || p.extension().and_then(|s| s.to_str()) != Some("json") + { + return false; + } + p.file_stem().and_then(|s| s.to_str()).is_some_and(|s| { + let parts: Vec<_> = s.split('-').collect(); + let (g, i) = match parts.as_slice() { + [g, i] => (*g, *i), + [g, tx, i] + if tx.len() == 32 + && tx + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) => + { + (*g, *i) + } + _ => return false, + }; + g.parse::() + .is_ok_and(|n| n > 0 && n <= 9_007_199_254_740_991) + && i.parse::().is_ok_and(|n| n < 32) + }) + } + fn clean_unreferenced(&self, record: Option<&Record>) -> Result<()> { + let referenced: std::collections::HashSet<_> = record + .and_then(|r| r.pending.as_ref()) + .map(|j| { + j.changes + .iter() + .map(|c| Path::new(&c.journal_file)) + .collect() + }) + .unwrap_or_default(); + for entry in io(fs::read_dir(self.root.join("journal")))? { + let entry = io(entry)?; + let path = entry.path(); + if io(entry.file_type())?.is_file() + && self.journal_path(&path.to_string_lossy()) + && !referenced.contains(path.as_path()) + { + io(fs::remove_file(path))?; + } + } + sync_dir(&self.root.join("journal")) + } + pub fn validate(&self, r: &Record, nested: bool) -> Result<()> { + self.validate_for(r, nested, platform()) + } + fn validate_for(&self, r: &Record, nested: bool, host: &str) -> Result<()> { + let invalid = || "record-invalid".to_owned(); + if r.version != 1 + || Uuid::parse_str(&r.owner_id).is_err() + || r.install_id.is_empty() + || r.install_id.len() > 256 + || r.generation == 0 + || r.generation > 9_007_199_254_740_991 + { + return Err(invalid()); + } + if let Some(b) = &r.bundle { + if !bundle_valid(b, host) + || (b.platform == "win32" && r.posix.is_some()) + || (b.platform != "win32" && r.windows.is_some()) + { + return Err(invalid()); + } + } else if r.enabled || r.posix.is_some() || r.windows.is_some() { + return Err(invalid()); + } + if let Some(p) = &r.posix { + if p.bin_directory != self.root.join("bin").to_string_lossy() + || p.files.len() > 2 + || p.rc_files.len() > 16 + { + return Err(invalid()); + } + let mut paths = std::collections::HashSet::new(); + for f in &p.files { + let expected = match f.kind.as_str() { + "shim" => self.root.join("bin/ocx"), + "path-helper" => self.root.join("path.sh"), + _ => return Err(invalid()), + }; + if Path::new(&f.path) != expected || !digest(&f.sha256) || !paths.insert(&f.path) { + return Err(invalid()); + } + } + for f in &p.rc_files { + if !matches!(f.shell.as_str(), "zsh" | "bash" | "fish") + || !absolute(&f.path) + || !paths.insert(&f.path) + || !digest(&f.block_sha256) + || f.result != "installed" + || f.backup_path + .as_deref() + .is_some_and(|s| !self.backup_path(s)) + { + return Err(invalid()); + } + } + } + if let Some(w) = &r.windows { + if w.key != "HKCU\\Environment" + || w.value != "Path" + || !absolute_for(&w.entry, "win32") + || w.entry.contains(';') + || !matches!(w.value_type.as_str(), "REG_SZ" | "REG_EXPAND_SZ") + || !matches!(w.action.as_str(), "inserted" | "moved-existing") + || [&w.previous_before, &w.previous_after].iter().any(|s| { + s.as_ref() + .is_some_and(|s| s.contains([';', '\0', '\r', '\n'])) + }) + { + return Err(invalid()); + } + } + if let Some(j) = &r.pending { + if nested + || !matches!(j.operation.as_str(), "install" | "remove") + || j.changes.len() > 32 + || j.next.pending.is_some() + || r.owner_id != j.next.owner_id + || r.install_id != j.next.install_id + || r.enabled != j.next.enabled + || j.next.generation != r.generation + 1 + { + return Err(invalid()); + } + self.validate_for(&j.next, true, host)?; + let mut paths = std::collections::HashSet::new(); + let mut journals = std::collections::HashSet::new(); + for c in &j.changes { + if !paths.insert(&c.path) + || !journals.insert(&c.journal_file) + || !self.journal_path(&c.journal_file) + || ![&c.before_sha256, &c.after_sha256] + .iter() + .all(|s| *s == "absent" || digest(s)) + || c.backup_path + .as_deref() + .is_some_and(|s| !self.backup_path(s)) + { + return Err(invalid()); + } + match c.kind.as_str() { + "file" if self.owned_path(&c.path, r, &j.next) && c.mode <= 0o777 => {} + "registry-sz" | "registry-expand" + if host == "win32" + && c.path == "HKCU\\Environment\\Path" + && c.mode == 0 + && c.backup_path.is_none() => {} + _ => return Err(invalid()), + } + } + } + Ok(()) + } + pub fn read(&self) -> Result> { + let Some(b) = private_bytes( + &self.root.join("cli.json"), + RECORD_LIMIT, + "record-too-large", + )? + else { + return Ok(None); + }; + let r: Record = serde_json::from_slice(&b).map_err(|_| "record-invalid")?; + self.validate(&r, false)?; + Ok(Some(r)) + } + fn encoded(&self, r: &Record) -> Result> { + self.validate(r, false)?; + let b = serde_json::to_vec(r).map_err(|_| "record-invalid")?; + if b.len() > RECORD_LIMIT { + return Err("record-too-large".into()); + } + Ok(b) + } + pub fn save(&self, r: &Record) -> Result<()> { + let b = self.encoded(r)?; + let p = self.root.join("cli.json"); + let old = private_bytes(&p, RECORD_LIMIT, "record-too-large")?; + if old.as_deref() == Some(b.as_slice()) { + return Ok(()); + } + atomic(&p, old.as_deref(), &b, 0o600) + } + fn prepare( + &self, + current: &Record, + mut next: Record, + changes: &[Change], + op: &str, + ) -> Result { + next.generation = current.generation + 1; + next.pending = None; + let registry_changed = changes.iter().any(|c| c.kind.starts_with("registry-")); + next.notify_pending |= registry_changed || current.notify_pending; + let txid = Uuid::new_v4().simple().to_string(); + let mut refs = Vec::new(); + let mut payloads = Vec::new(); + for (i, c) in changes.iter().enumerate() { + let journal_file = self + .root + .join("journal") + .join(format!("{}-{txid}-{i}.json", next.generation)); + let payload = JournalFile { + version: 1, + kind: c.kind.clone(), + path: c.path.clone(), + mode: (c.kind == "file").then_some(c.mode), + before: c.before.as_ref().map(|b| STANDARD.encode(b)), + after: c.after.as_ref().map(|b| STANDARD.encode(b)), + }; + let bytes = serde_json::to_vec(&payload).map_err(|_| "journal-invalid")?; + if bytes.len() > JOURNAL_LIMIT { + return Err("rc-too-large".into()); + } + refs.push(PendingChange { + kind: c.kind.clone(), + path: c.path.clone(), + mode: c.mode, + backup_path: c.backup_path.clone(), + journal_file: journal_file.to_string_lossy().into_owned(), + before_sha256: fingerprint(c.before.as_deref()), + after_sha256: fingerprint(c.after.as_deref()), + }); + payloads.push((journal_file, bytes)); + } + let mut pending = current.clone(); + pending.notify_pending |= registry_changed; + pending.pending = Some(Journal { + operation: op.into(), + changes: refs, + next: Box::new(next), + }); + self.encoded(&pending)?; // Validate all paths and the 64 KiB bound before creating any journal. + for (path, bytes) in payloads { + let old = private_bytes(&path, JOURNAL_LIMIT, "journal-too-large")?; + if old.as_deref().is_some_and(|old| old != bytes) { + return Err("journal-conflict".into()); + } + if old.is_none() { + atomic(&path, None, &bytes, 0o600)?; + } + } + sync_dir(&self.root.join("journal"))?; + Ok(pending) + } + pub fn transact( + &self, + current: &mut Record, + next: Record, + changes: Vec, + op: &str, + ) -> Result<()> { + if changes.is_empty() && *current == next { + return Ok(()); + } + let pending = self.prepare(current, next, &changes, op)?; + self.save(&pending)?; + *current = pending; + self.recover(current) + } + fn load_change(&self, c: &PendingChange) -> Result { + let bytes = private_bytes( + Path::new(&c.journal_file), + JOURNAL_LIMIT, + "journal-too-large", + )? + .ok_or("journal-missing")?; + let j: JournalFile = serde_json::from_slice(&bytes).map_err(|_| "journal-invalid")?; + let decode = |s: Option| { + s.map(|s| STANDARD.decode(s).map_err(|_| "journal-invalid".to_owned())) + .transpose() + }; + let before = decode(j.before)?; + let after = decode(j.after)?; + if j.version != 1 + || j.kind != c.kind + || j.path != c.path + || j.mode != (c.kind == "file").then_some(c.mode) + || fingerprint(before.as_deref()) != c.before_sha256 + || fingerprint(after.as_deref()) != c.after_sha256 + { + return Err("journal-digest-mismatch".into()); + } + Ok(Change { + kind: c.kind.clone(), + path: c.path.clone(), + before, + after, + mode: c.mode, + backup_path: c.backup_path.clone(), + }) + } + pub fn recover(&self, current: &mut Record) -> Result<()> { + self.recover_with( + current, + crate::cli_command_windows::read_change, + crate::cli_command_windows::apply_change, + ) + } + fn recover_with( + &self, + current: &mut Record, + mut read_registry: impl FnMut(&Change) -> Result>>, + mut write_registry: impl FnMut(&Change) -> Result<()>, + ) -> Result<()> { + self.validate(current, false)?; + let Some(j) = current.pending.clone() else { + return Ok(()); + }; + let wanted = (j.operation == "install") == current.enabled; + if j.changes.iter().any(|c| c.kind.starts_with("registry-")) && !current.notify_pending { + current.notify_pending = true; + self.save(current)?; + } + // State table for BOTH operations: wanted: before -> after, after -> done. + // Cancelled: before -> leave, after -> before (reverse completed prefix). + // Any third state or unverifiable journal -> stop, preserve pending and backups. + let mut changes = j + .changes + .iter() + .map(|c| self.load_change(c)) + .collect::>>()?; + if !wanted { + changes.reverse(); + } + for c in changes { + if c.kind == "file" && !Path::new(&c.path).starts_with(&self.root) { + rc_acl_check(Path::new(&c.path))?; + } + let (before, after) = if wanted { + (&c.before, &c.after) + } else { + (&c.after, &c.before) + }; + let found = if c.kind == "file" { + read_bytes(Path::new(&c.path))? + } else { + read_registry(&c)? + }; + if found == *after { + continue; + } + if found != *before { + return Err("journal-conflict".into()); + } + if wanted { + if let (Some(name), Some(bytes)) = (&c.backup_path, &c.before) { + let p = Path::new(name); + if let Some(old) = private_bytes(p, JOURNAL_LIMIT, "backup-too-large")? { + if old != *bytes { + return Err("backup-conflict".into()); + } + } else { + atomic(p, None, bytes, 0o600)?; + } + } + } + if c.kind == "file" { + let p = Path::new(&c.path); + if !p.starts_with(&self.root) { + rc_acl_check(p)?; + } + if let Some(b) = after { + atomic(p, before.as_deref(), b, c.mode)?; + } else { + if read_bytes(p)? != *before { + return Err("concurrent-edit".into()); + } + io(fs::remove_file(p))?; + sync_dir(p.parent().ok_or("unsafe-file")?)?; + } + } else { + // Old pending records also acquire debt before replay/rollback writes. + if !current.notify_pending { + current.notify_pending = true; + self.save(current)?; + } + let mut step = c.clone(); + step.before = before.clone(); + step.after = after.clone(); + write_registry(&step)?; + } + } + let mut settled = if wanted { *j.next } else { current.clone() }; + settled.pending = None; + settled.notify_pending |= current.notify_pending; + settled.generation = current.generation + 1; + self.save(&settled)?; + *current = settled; + for c in j.changes { + io(fs::remove_file(c.journal_file))?; + } + sync_dir(&self.root.join("journal")) + } + pub fn notify_with( + &self, + current: &mut Record, + broadcast: impl FnOnce() -> Result<()>, + ) -> Result<()> { + if current.notify_pending { + broadcast()?; + let mut settled = current.clone(); + settled.notify_pending = false; + self.save(&settled)?; + *current = settled; + } + Ok(()) + } + pub fn journal_issues(&self, r: &Record) -> Vec { + let referenced: std::collections::HashSet<_> = r + .pending + .as_ref() + .map(|j| j.changes.iter().map(|c| c.journal_file.as_str()).collect()) + .unwrap_or_default(); + let orphan = fs::read_dir(self.root.join("journal")) + .map(|v| { + v.filter_map(|e| e.ok()) + .any(|e| !referenced.contains(e.path().to_string_lossy().as_ref())) + }) + .unwrap_or(true); + if orphan { + vec!["orphan-journal".into()] + } else { + Vec::new() + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + pub(crate) struct Temp(pub PathBuf); + impl Temp { + pub(crate) fn new() -> Self { + let p = fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!("ocx-cli-{}", Uuid::new_v4())); + private_dir(&p).unwrap(); + Self(p) + } + pub(crate) fn store(&self) -> Store { + Store::open(self.0.join("record"), vec![self.0.join(".zshrc")]).unwrap() + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + pub(crate) fn bundle() -> Bundle { + let (app, cli, kind) = match platform() { + "win32" => ( + "C:\\Desktop\\app.exe", + "C:\\Desktop\\ocx.exe", + "windows-install", + ), + "darwin" => ( + "/Applications/Desktop.app/Contents/MacOS/app", + "/Applications/Desktop.app/Contents/MacOS/ocx", + "macos-app", + ), + _ => ("/usr/bin/app", "/usr/bin/ocx", "linux-deb"), + }; + Bundle { + platform: platform().into(), + kind: kind.into(), + app_executable: app.into(), + cli_executable: cli.into(), + version: "1".into(), + } + } + fn change(p: &Path) -> Change { + Change { + kind: "file".into(), + path: p.to_string_lossy().into_owned(), + before: Some(b"old".to_vec()), + after: Some(b"new".to_vec()), + mode: 0o600, + backup_path: None, + } + } + #[test] + fn shared_record_fixtures_match_host_contract_and_first_pending_shape() { + let t = Temp::new(); + let s = t.store(); + for (json, host, valid) in [ + ( + include_str!("../../../tests/fixtures/desktop-cli-record/notify-pending.json"), + "win32", + true, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/valid-darwin.json"), + "darwin", + true, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/valid-win32.json"), + "win32", + true, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/valid-linux.json"), + "linux", + true, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/disabled-tombstone.json"), + "darwin", + true, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/pending-enabled.json"), + "darwin", + true, + ), + ( + include_str!( + "../../../tests/fixtures/desktop-cli-record/disabled-with-pending.json" + ), + "darwin", + true, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/appimage-kind.json"), + "darwin", + false, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/relative-target.json"), + "darwin", + false, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/dotdot-target.json"), + "darwin", + false, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/first-pending.json"), + "darwin", + true, + ), + ] { + // Windows roots carry backslashes, which must stay escaped inside the JSON text. + let root = s.root.to_str().unwrap().replace('\\', "\\\\"); + let json = json.replace("/fixture/desktop", &root); + let r: Record = serde_json::from_str(&json).unwrap(); + // Path rules follow the real host, so a Windows test run cannot simulate a POSIX host. + // There it checks the real contract instead: any record carrying another platform's + // bundle is refused, and only a bundle-less disabled tombstone stays valid. + if cfg!(windows) && host != "win32" { + let tombstone = !r.enabled && r.bundle.is_none(); + assert_eq!( + s.validate(&r, false).is_ok(), + tombstone, + "{host} fixture on win32" + ); + continue; + } + assert_eq!( + s.validate_for(&r, false, host).is_ok(), + valid, + "{host} fixture: {json}" + ); + if r.enabled && r.bundle.as_ref().unwrap().platform != platform() { + assert!(s.validate(&r, false).is_err()); + } + } + } + #[test] + fn record_roundtrip_validation_and_limits() { + let t = Temp::new(); + let s = t.store(); + let r = Record::fresh(bundle()); + s.save(&r).unwrap(); + assert_eq!(s.read().unwrap(), Some(r.clone())); + let mut bad = r.clone(); + bad.version = 2; + assert!(s.save(&bad).is_err()); + bad = r.clone(); + bad.bundle = None; + assert!(s.save(&bad).is_err()); + bad = r.clone(); + bad.generation = 9_007_199_254_740_992; + assert!(s.save(&bad).is_err()); + bad = r.clone(); + bad.owner_id = "bad".into(); + assert!(s.save(&bad).is_err()); + bad = r.clone(); + bad.bundle.as_mut().unwrap().version = "x".repeat(RECORD_LIMIT); + assert_eq!(s.save(&bad).unwrap_err(), "record-too-large"); + let p = s.root.join("cli.json"); + let old = read_bytes(&p).unwrap().unwrap(); + atomic(&p, Some(&old), &vec![b' '; RECORD_LIMIT + 1], 0o600).unwrap(); + assert_eq!(s.read().unwrap_err(), "record-too-large"); + } + #[test] + fn unsaved_first_transaction_journals_are_cleaned_on_reopen() { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + atomic(&p, None, b"old", 0o600).unwrap(); + let mut r = Record::fresh(bundle()); + let pending = s.prepare(&r, r.clone(), &[change(&p)], "install").unwrap(); + let leftover = pending.pending.unwrap().changes[0].journal_file.clone(); + assert!(Path::new(&leftover).is_file()); + drop(s); + let s = t.store(); + assert!(!Path::new(&leftover).exists()); + let mut c = change(&p); + c.after = Some(b"different".to_vec()); + s.transact(&mut r.clone(), r.clone(), vec![c], "install") + .unwrap(); + r = s.read().unwrap().unwrap(); + assert!(r.pending.is_none()); + assert_eq!(fs::read(p).unwrap(), b"different"); + } + #[test] + fn final_save_before_journal_deletion_is_cleaned_on_reopen() { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + let r = Record::fresh(bundle()); + atomic(&p, None, b"old", 0o600).unwrap(); + let pending = s.prepare(&r, r.clone(), &[change(&p)], "install").unwrap(); + let journal = pending.pending.as_ref().unwrap(); + atomic(&p, Some(b"old"), b"new", 0o600).unwrap(); + s.save(&journal.next).unwrap(); + let leftover = journal.changes[0].journal_file.clone(); + drop(s); + let s = t.store(); + assert!(!Path::new(&leftover).exists()); + assert!(s.read().unwrap().unwrap().pending.is_none()); + assert_eq!(fs::read(p).unwrap(), b"new"); + } + #[test] + fn corrupt_record_preserves_all_journals_on_reopen() { + let t = Temp::new(); + let s = t.store(); + let r = Record::fresh(bundle()); + let pending = s + .prepare(&r, r.clone(), &[change(&t.0.join(".zshrc"))], "install") + .unwrap(); + let leftover = pending.pending.unwrap().changes[0].journal_file.clone(); + let bytes = fs::read(&leftover).unwrap(); + atomic(&s.root.join("cli.json"), None, b"{", 0o600).unwrap(); + drop(s); + assert!(Store::open(t.0.join("record"), vec![]).is_err()); + assert!(fs::read(leftover).unwrap() == bytes); + } + #[test] + fn referenced_legacy_and_txid_journals_survive_cleanup() { + for legacy in [false, true] { + let t = Temp::new(); + let s = t.store(); + let r = Record::fresh(bundle()); + let p = t.0.join(".zshrc"); + atomic(&p, None, b"old", 0o600).unwrap(); + let mut pending = s.prepare(&r, r.clone(), &[change(&p)], "install").unwrap(); + let c = &mut pending.pending.as_mut().unwrap().changes[0]; + if legacy { + let old = s.root.join("journal/2-0.json"); + fs::rename(&c.journal_file, &old).unwrap(); + c.journal_file = old.to_string_lossy().into_owned(); + } + let referenced = c.journal_file.clone(); + s.save(&pending).unwrap(); + atomic(&s.root.join("journal/unrelated.txt"), None, b"keep", 0o600).unwrap(); + drop(s); + let s = t.store(); + assert!(Path::new(&referenced).is_file()); + assert!(s.root.join("journal/unrelated.txt").is_file()); + s.recover(&mut pending).unwrap(); + assert_eq!(fs::read(p).unwrap(), b"new"); + } + } + #[test] + fn broadcast_debt_survives_failure_and_remove_then_retries_once() { + let t = Temp::new(); + let s = t.store(); + let mut r = Record::fresh(bundle()); + r.notify_pending = true; + s.save(&r).unwrap(); + assert_eq!( + s.notify_with(&mut r, || Err("environment-broadcast-failed".into())) + .unwrap_err(), + "environment-broadcast-failed" + ); + assert!(s.read().unwrap().unwrap().notify_pending); + r.enabled = false; + r.windows = None; + s.save(&r).unwrap(); + drop(s); + let s = t.store(); + let mut r = s.read().unwrap().unwrap(); + let count = std::cell::Cell::new(0); + s.notify_with(&mut r, || { + count.set(count.get() + 1); + Ok(()) + }) + .unwrap(); + s.notify_with(&mut r, || panic!("settled debt must not broadcast again")) + .unwrap(); + assert_eq!(count.get(), 1); + assert!(!s.read().unwrap().unwrap().notify_pending); + } + #[cfg(windows)] + #[test] + fn registry_install_remove_recovery_and_rollback_keep_durable_broadcast_debt() { + use std::cell::RefCell; + for scenario in ["install", "remove", "rollback", "crash-after-write"] { + let t = Temp::new(); + let s = t.store(); + let mut current = Record::fresh(bundle()); + let mut next = current.clone(); + let (_, owned) = + crate::cli_command_windows::prepend("old", r"C:\Desktop", "REG_SZ").unwrap(); + if scenario == "remove" { + current.enabled = false; + current.windows = Some(owned); + next = current.clone(); + next.windows = None; + } else { + next.windows = Some(owned); + } + let c = Change { + kind: "registry-sz".into(), + path: "HKCU\\Environment\\Path".into(), + before: Some(b"old".to_vec()), + after: Some(b"new".to_vec()), + mode: 0, + backup_path: None, + }; + current = s + .prepare( + ¤t, + next, + &[c], + if scenario == "remove" { + "remove" + } else { + "install" + }, + ) + .unwrap(); + assert!(current.notify_pending); + s.save(¤t).unwrap(); + let registry = RefCell::new(Some( + if matches!(scenario, "rollback" | "crash-after-write") { + b"new".to_vec() + } else { + b"old".to_vec() + }, + )); + if scenario == "rollback" { + current.enabled = false; + current.pending.as_mut().unwrap().next.enabled = false; + // Exercise legacy debt acquisition in the save that starts rollback. + current.notify_pending = false; + s.save(¤t).unwrap(); + } + s.recover_with( + &mut current, + |_| Ok(registry.borrow().clone()), + |c| { + assert!(s.read().unwrap().unwrap().notify_pending); + *registry.borrow_mut() = c.after.clone(); + Ok(()) + }, + ) + .unwrap(); + assert!(current.pending.is_none() && current.notify_pending); + assert_eq!( + registry.into_inner().unwrap(), + if scenario == "rollback" { + b"old" + } else { + b"new" + } + ); + if scenario == "remove" { + assert!(current.windows.is_none()); + } + assert!(s + .notify_with(&mut current, || Err("environment-broadcast-failed".into())) + .is_err()); + assert!(s.read().unwrap().unwrap().notify_pending); + drop(s); + let s = t.store(); + let mut current = s.read().unwrap().unwrap(); + let count = std::cell::Cell::new(0); + s.notify_with(&mut current, || { + count.set(count.get() + 1); + Ok(()) + }) + .unwrap(); + s.notify_with(&mut current, || panic!("broadcast-must-not-repeat")) + .unwrap(); + assert_eq!(count.get(), 1); + assert!(!s.read().unwrap().unwrap().notify_pending); + } + } + #[cfg(target_os = "macos")] + #[test] + fn rc_acl_refuses_replay_and_rollback_without_losing_pending_ownership() { + for rollback in [false, true] { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + let mut r = Record::fresh(bundle()); + atomic(&p, None, if rollback { b"new" } else { b"old" }, 0o600).unwrap(); + r = s.prepare(&r, r.clone(), &[change(&p)], "install").unwrap(); + if rollback { + r.enabled = false; + r.pending.as_mut().unwrap().next.enabled = false; + } + s.save(&r).unwrap(); + chmod_acl(&p, true); + assert_eq!(s.recover(&mut r).unwrap_err(), "rc-acl-present"); + assert!(s.read().unwrap().unwrap().pending.is_some()); + assert_eq!( + fs::read(&p).unwrap(), + if rollback { b"new" } else { b"old" } + ); + assert!(rc_acl_check(&p).is_err()); + chmod_acl(&p, false); + s.recover(&mut r).unwrap(); + assert_eq!(fs::read(p).unwrap(), if rollback { b"old" } else { b"new" }); + } + } + #[cfg(target_os = "macos")] + pub(crate) fn chmod_acl(path: &Path, add: bool) { + let mut command = std::process::Command::new("/bin/chmod"); + if add { + command.args(["+a", if path.is_dir() { "everyone allow read,readattr,readextattr,readsecurity,file_inherit,directory_inherit" } else { "everyone allow read,readattr,readextattr,readsecurity" }]); + } else { + command.arg("-N"); + } + assert!( + command + .arg(path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap() + .success(), + "acl-command-failed" + ); + } + #[cfg(target_os = "macos")] + #[test] + fn acl_empty_missing_inherited_and_new_store_contract() { + let t = Temp::new(); + assert!(!acl::present(&t.0).unwrap()); + assert!(acl::present(&t.0.join("missing")).is_err()); + chmod_acl(&t.0, true); + let inherited = t.0.join("inherited"); + fs::create_dir(&inherited).unwrap(); + fs::set_permissions(&inherited, { + use std::os::unix::fs::PermissionsExt; + fs::Permissions::from_mode(0o700) + }) + .unwrap(); + assert!(acl::present(&inherited).unwrap()); + assert!(private_dir(&inherited).is_err()); + chmod_acl(&inherited, false); + private_dir(&inherited).unwrap(); + let s = t.store(); + assert!(!acl::present(&s.root).unwrap()); + atomic(&s.root.join("journal/2-0.json"), None, b"test", 0o600).unwrap(); + assert!(!acl::present(&s.root.join("journal/2-0.json")).unwrap()); + let root = s.root.clone(); + drop(s); + Store::open(root, vec![]).unwrap(); + } + #[cfg(target_os = "macos")] + #[test] + fn existing_private_acl_is_refused_before_any_store_write() { + for name in [ + "", + "bin", + "journal", + "backups", + "cli.json", + "cli.lock", + "bin/ocx", + "path.sh", + "journal/2-0.json", + "backups/test", + ] { + let t = Temp::new(); + let s = t.store(); + s.save(&Record::fresh(bundle())).unwrap(); + for (file, mode) in [ + ("bin/ocx", 0o700), + ("path.sh", 0o600), + ("journal/2-0.json", 0o600), + ("backups/test", 0o600), + ] { + atomic(&s.root.join(file), None, b"keep", mode).unwrap(); + } + let record = fs::read(s.root.join("cli.json")).unwrap(); + let root = s.root.clone(); + drop(s); + chmod_acl(&root.join(name), true); + assert!(Store::open(root.clone(), vec![]).is_err()); + assert!(fs::read(root.join("cli.json")).unwrap() == record); + assert!(root.join("journal/2-0.json").is_file()); + chmod_acl(&root.join(name), false); + } + } + #[cfg(target_os = "linux")] + #[test] + fn linux_acl_inheritance_clear_and_existing_child_refusal() { + let t = Temp::new(); + let set = |path: &Path, spec: &str| { + std::process::Command::new("setfacl") + .args(["-m", spec]) + .arg(path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + }; + let result = set(&t.0, "d:u:65534:rwx"); + if result + .as_ref() + .is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) + { + eprintln!("acl-test-skipped-setfacl-unavailable"); + return; + } + assert!(result.unwrap().success(), "acl-command-failed"); + let s = t.store(); + assert!(!acl::present(&s.root).unwrap()); + let p = s.root.join("backups/test"); + atomic(&p, None, b"keep", 0o600).unwrap(); + assert!(!acl::present(&p).unwrap()); + assert!(set(&p, "u:65534:r--").unwrap().success()); + // Keep the numeric mode private so this negative specifically proves ACL inspection. + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&p, fs::Permissions::from_mode(0o600)).unwrap(); + } + let root = s.root.clone(); + drop(s); + assert!(Store::open(root, vec![]).is_err()); + } + #[test] + fn corrupt_record_does_not_become_first_run() { + let t = Temp::new(); + let s = t.store(); + atomic(&s.root.join("cli.json"), None, b"{", 0o600).unwrap(); + assert_eq!(s.read().unwrap_err(), "record-invalid"); + } + #[test] + fn external_journal_recovery_state_table_and_idempotence() { + for op in ["install", "remove"] { + for wanted in [false, true] { + for state in ["before", "after", "other"] { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + let mut r = Record::fresh(bundle()); + r.enabled = (op == "install") == wanted; + let c = change(&p); + let bytes = match state { + "before" => b"old", + "after" => b"new", + _ => b"usr", + }; + atomic(&p, None, bytes, 0o600).unwrap(); + r = s.prepare(&r, r.clone(), &[c], op).unwrap(); + s.save(&r).unwrap(); + let result = s.recover(&mut r); + if state == "other" { + assert_eq!(result.unwrap_err(), "journal-conflict"); + assert_eq!(fs::read(&p).unwrap(), b"usr"); + assert!(s.read().unwrap().unwrap().pending.is_some()); + } else { + result.unwrap(); + assert_eq!(fs::read(&p).unwrap(), if wanted { b"new" } else { b"old" }); + assert!(r.pending.is_none()); + s.recover(&mut r).unwrap(); + assert_eq!(fs::read_dir(s.root.join("journal")).unwrap().count(), 0); + } + } + } + } + } + #[test] + fn journal_digest_mismatch_and_unauthorized_paths_are_refused() { + let t = Temp::new(); + let s = t.store(); + let mut r = Record::fresh(bundle()); + assert!(s + .prepare(&r, r.clone(), &[change(&t.0.join("unowned"))], "install") + .is_err()); + r = s + .prepare(&r, r.clone(), &[change(&t.0.join(".zshrc"))], "install") + .unwrap(); + let c = &r.pending.as_ref().unwrap().changes[0]; + let p = Path::new(&c.journal_file); + let old = read_bytes(p).unwrap().unwrap(); + let mut j: JournalFile = serde_json::from_slice(&old).unwrap(); + j.after = Some(STANDARD.encode(b"tampered")); + atomic(p, Some(&old), &serde_json::to_vec(&j).unwrap(), 0o600).unwrap(); + s.save(&r).unwrap(); + assert_eq!(s.recover(&mut r).unwrap_err(), "journal-digest-mismatch"); + assert!(s.read().unwrap().unwrap().pending.is_some()); + } + #[test] + fn oversize_journal_and_change_roster_are_refused_before_save() { + let t = Temp::new(); + let s = t.store(); + let r = Record::fresh(bundle()); + let mut c = change(&t.0.join(".zshrc")); + c.after = Some(vec![b'x'; JOURNAL_LIMIT]); + assert_eq!( + s.prepare(&r, r.clone(), &[c], "install").unwrap_err(), + "rc-too-large" + ); + assert!(s + .prepare( + &r, + r.clone(), + &vec![change(&t.0.join(".zshrc")); 33], + "install" + ) + .is_err()); + assert!(s.read().unwrap().is_none()); + assert_eq!(fs::read_dir(s.root.join("journal")).unwrap().count(), 0); + } + #[test] + fn disabled_pending_install_rolls_back_every_completed_prefix() { + for prefix in 0..=2 { + let t = Temp::new(); + let s = t.store(); + let mut r = Record::fresh(bundle()); + let changes: Vec<_> = [s.root.join("bin/ocx"), s.root.join("path.sh")] + .iter() + .map(|p| { + let mut c = change(p); + c.before = None; + c + }) + .collect(); + r = s.prepare(&r, r.clone(), &changes, "install").unwrap(); + s.save(&r).unwrap(); + for c in changes.iter().take(prefix) { + atomic(Path::new(&c.path), None, c.after.as_ref().unwrap(), c.mode).unwrap(); + } + r.enabled = false; + r.generation += 1; + let j = r.pending.as_mut().unwrap(); + j.next.enabled = false; + j.next.generation = r.generation + 1; + s.save(&r).unwrap(); + let mut reloaded = s.read().unwrap().unwrap(); + s.recover(&mut reloaded).unwrap(); + assert!(!reloaded.enabled && reloaded.pending.is_none()); + for c in changes { + assert!(!Path::new(&c.path).exists()); + } + } + } + #[test] + fn large_rc_bytes_stay_in_external_private_journal_and_backup() { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + let r = Record::fresh(bundle()); + let mut c = change(&p); + c.before = Some(vec![b'x'; 256 * 1024]); + c.backup_path = Some( + s.root + .join("backups/original") + .to_string_lossy() + .into_owned(), + ); + atomic(&p, None, c.before.as_ref().unwrap(), 0o600).unwrap(); + let mut pending = s.prepare(&r, r.clone(), &[c.clone()], "install").unwrap(); + s.save(&pending).unwrap(); + assert!(fs::metadata(s.root.join("cli.json")).unwrap().len() < RECORD_LIMIT as u64); + let json: serde_json::Value = + serde_json::from_slice(&fs::read(s.root.join("cli.json")).unwrap()).unwrap(); + assert!(json["pending"]["changes"][0].get("before").is_none()); + assert!(private_bytes( + Path::new(&pending.pending.as_ref().unwrap().changes[0].journal_file), + JOURNAL_LIMIT, + "limit" + ) + .unwrap() + .is_some()); + s.recover(&mut pending).unwrap(); + assert_eq!(fs::read(c.backup_path.unwrap()).unwrap(), c.before.unwrap()); + } + #[cfg(unix)] + #[test] + fn rc_ownership_limit_and_lexical_path_validation_are_independent_of_desired_targets() { + let t = Temp::new(); + let s = t.store(); + let mut r = Record::fresh(bundle()); + r.posix = Some(Posix { + bin_directory: s.root.join("bin").to_string_lossy().into_owned(), + files: vec![], + rc_files: (0..16) + .map(|i| RcFile { + shell: "zsh".into(), + path: t.0.join(format!("old-{i}")).to_string_lossy().into_owned(), + block_sha256: hash(b"block"), + created: true, + backup_path: None, + result: "installed".into(), + }) + .collect(), + }); + s.validate(&r, false).unwrap(); + let mut extra = r.posix.as_ref().unwrap().rc_files[0].clone(); + extra.path = t.0.join("extra").to_string_lossy().into_owned(); + r.posix.as_mut().unwrap().rc_files.push(extra); + assert!(s.validate(&r, false).is_err()); + r.posix.as_mut().unwrap().rc_files.pop(); + for bad in [ + "relative", + "/example/./ocx", + "/example/../ocx", + "/example/ocx\n", + "/example/ocx\0", + ] { + r.bundle.as_mut().unwrap().cli_executable = bad.into(); + assert!(s.validate(&r, false).is_err()); + } + r.bundle.as_mut().unwrap().cli_executable = format!("/{}", "x".repeat(4096)); + assert!(s.validate(&r, false).is_err()); + } + #[test] + fn lock_drop_releases_and_held_store_excludes_second_open() { + let t = Temp::new(); + let s = t.store(); + let root = s.root.clone(); + assert_eq!( + Store::open(root.clone(), vec![]).err().as_deref(), + Some("lock-busy") + ); + drop(s); + let reopened = Store::open(root.clone(), vec![]).unwrap(); + assert_eq!( + Store::open(root, vec![]).err().as_deref(), + Some("lock-busy") + ); + drop(reopened); + } + #[cfg(unix)] + #[test] + fn lock_drop_releases_even_with_duplicate_file_description() { + let t = Temp::new(); + let s = t.store(); + let root = s.root.clone(); + let duplicate = s._lock.file.try_clone().unwrap(); + drop(s); + let reopened = Store::open(root.clone(), vec![]).unwrap(); + assert_eq!( + Store::open(root, vec![]).err().as_deref(), + Some("lock-busy") + ); + assert_eq!(reopened._lock.release_for_test(), 0); + drop(duplicate); + } + #[cfg(unix)] + #[test] + fn lock_drop_releases_during_concurrent_process_spawns() { + use std::{ + process::Command, + sync::{atomic::AtomicBool, atomic::Ordering, Barrier}, + thread, + }; + // Present on macOS and on Linux runners; macOS has no /bin/true. + const TRUE: &str = "/usr/bin/true"; + let t = Temp::new(); + let root = t.store().root.clone(); + let stop = AtomicBool::new(false); + let ready = Barrier::new(5); + let mut busy = 0; + let mut errors = Vec::new(); + let spawns = thread::scope(|scope| { + let workers: Vec<_> = (0..4) + .map(|_| { + scope.spawn(|| { + ready.wait(); + let mut spawns = 0; + while !stop.load(Ordering::Relaxed) { + assert!(Command::new(TRUE).status().unwrap().success()); + spawns += 1; + } + spawns + }) + }) + .collect(); + ready.wait(); + for _ in 0..2000 { + // Each cycle opens, drops and reopens, without waiting or retrying a busy lock. + for _ in 0..2 { + match Store::open(root.clone(), vec![]) { + Ok(store) => drop(store), + Err(e) if e == "lock-busy" => busy += 1, + Err(e) => errors.push(e), + } + } + } + stop.store(true, Ordering::Relaxed); + workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .sum::() + }); + eprintln!("lock stress: cycles=2000, spawners=4, spawns={spawns}, lock-busy={busy}"); + assert!(spawns >= 4, "process spawners did not run"); + assert!(errors.is_empty(), "unexpected open errors: {errors:?}"); + assert_eq!(busy, 0); + } + #[test] + fn failed_open_after_lock_acquisition_releases_lock() { + let t = Temp::new(); + let s = t.store(); + let root = s.root.clone(); + atomic(&root.join("cli.json"), None, b"{", 0o600).unwrap(); + drop(s); + assert_eq!( + Store::open(root.clone(), vec![]).err().as_deref(), + Some("record-invalid") + ); + let guard = lock(&root).unwrap(); + assert_eq!(lock(&root).err().as_deref(), Some("lock-busy")); + drop(guard); + let _guard = lock(&root).unwrap(); + } + #[cfg(windows)] + #[test] + fn lock_guard_windows_explicit_release_allows_second_handle_to_relock() { + let t = Temp::new(); + let s = t.store(); + assert_ne!(s._lock.release_for_test(), 0); + let root = s.root.clone(); + let second = lock(&root).unwrap(); + drop(s); + assert_eq!(lock(&root).err().as_deref(), Some("lock-busy")); + assert_ne!(second.release_for_test(), 0); + let _third = lock(&root).unwrap(); + } + #[test] + fn lock_and_atomic_write_preserve_concurrent_user_edits() { + let t = Temp::new(); + let s = t.store(); + assert!(Store::open(s.root.clone(), vec![]).is_err()); + drop(s); + let _s = t.store(); + let p = t.0.join("file"); + atomic(&p, None, b"user", 0o600).unwrap(); + assert_eq!( + atomic(&p, Some(b"old"), b"replacement", 0o600).unwrap_err(), + "concurrent-edit" + ); + assert_eq!(fs::read(p).unwrap(), b"user"); + } + #[cfg(unix)] + #[test] + fn symlinks_special_files_readonly_and_unsafe_parents_are_refused() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let t = Temp::new(); + let p = t.0.join("file"); + atomic(&p, None, b"bytes", 0o600).unwrap(); + symlink(&p, t.0.join("link")).unwrap(); + assert!(read_bytes(&t.0.join("link")).is_err()); + assert!(check(&t.0, false).is_err()); + fs::set_permissions(&p, fs::Permissions::from_mode(0o400)).unwrap(); + assert!(read_bytes(&p).is_err()); + symlink(&t.0, t.0.join("parent")).unwrap(); + assert!(atomic(&t.0.join("parent/child"), None, b"x", 0o600).is_err()); + } +} diff --git a/desktop/src-tauri/src/cli_command_windows.rs b/desktop/src-tauri/src/cli_command_windows.rs new file mode 100644 index 00000000000..34c6220d1c6 --- /dev/null +++ b/desktop/src-tauri/src/cli_command_windows.rs @@ -0,0 +1,1277 @@ +#[cfg(any(windows, test))] +use crate::cli_command_record::Windows; +#[cfg(windows)] +use crate::cli_command_record::{Bundle, Record}; +use crate::cli_command_record::{Change, Result}; +#[cfg(windows)] +use std::path::Path; +#[cfg(windows)] +pub(crate) mod private_acl { + use super::*; + use std::{ffi::c_void, os::windows::ffi::OsStrExt, ptr}; + type Ptr = *mut c_void; + const UNSAFE: &str = "private-acl-unsafe"; + const ACL_REVISION: u32 = 2; + const FULL_CONTROL: u32 = 0x1f01ff; + const OBJECT_INHERIT_ACE: u32 = 0x1; + const CONTAINER_INHERIT_ACE: u32 = 0x2; + const SE_FILE_OBJECT: u32 = 1; + const DACL_SECURITY_INFORMATION: u32 = 4; + const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x80000000; + #[repr(C)] + struct Acl { + revision: u8, + reserved: u8, + size: u16, + count: u16, + reserved2: u16, + } + #[repr(C)] + struct AceHeader { + kind: u8, + flags: u8, + size: u16, + } + #[repr(C)] + struct Sid { + revision: u8, + count: u8, + authority: [u8; 6], + sub: [u32; 2], + } + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: Ptr, access: u32, token: *mut Ptr) -> i32; + fn GetTokenInformation( + token: Ptr, + class: u32, + info: Ptr, + size: u32, + needed: *mut u32, + ) -> i32; + fn CreateWellKnownSid(kind: u32, domain: Ptr, sid: Ptr, size: *mut u32) -> i32; + fn InitializeAcl(acl: *mut Acl, size: u32, revision: u32) -> i32; + fn AddAccessAllowedAceEx( + acl: *mut Acl, + revision: u32, + flags: u32, + mask: u32, + sid: Ptr, + ) -> i32; + fn SetNamedSecurityInfoW( + name: *mut u16, + kind: u32, + info: u32, + owner: Ptr, + group: Ptr, + dacl: *mut Acl, + sacl: *mut Acl, + ) -> u32; + fn GetNamedSecurityInfoW( + name: *const u16, + kind: u32, + info: u32, + owner: *mut Ptr, + group: *mut Ptr, + dacl: *mut *mut Acl, + sacl: *mut *mut Acl, + descriptor: *mut Ptr, + ) -> u32; + fn GetSecurityDescriptorControl( + descriptor: Ptr, + control: *mut u16, + revision: *mut u32, + ) -> i32; + fn GetSecurityDescriptorDacl( + descriptor: Ptr, + present: *mut i32, + dacl: *mut *mut Acl, + defaulted: *mut i32, + ) -> i32; + fn EqualSid(a: Ptr, b: Ptr) -> i32; + fn IsValidSid(sid: Ptr) -> i32; + fn IsValidAcl(acl: *mut Acl) -> i32; + fn GetAce(acl: *mut Acl, index: u32, ace: *mut Ptr) -> i32; + fn GetLengthSid(sid: Ptr) -> u32; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> Ptr; + fn CloseHandle(handle: Ptr) -> i32; + fn LocalFree(memory: Ptr) -> Ptr; + } + struct Local(Ptr); + impl Drop for Local { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + LocalFree(self.0); + } + } + } + } + struct Token(Ptr); + impl Drop for Token { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } + fn user() -> Result> { + let mut token = ptr::null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), 0x8, &mut token) } == 0 { + return Err(UNSAFE.into()); + } + let token = Token(token); + let mut needed = 0; + unsafe { + GetTokenInformation(token.0, 1, ptr::null_mut(), 0, &mut needed); + } + if needed < std::mem::size_of::() as u32 || needed > 65536 { + return Err(UNSAFE.into()); + } + let mut bytes = vec![0usize; (needed as usize).div_ceil(std::mem::size_of::())]; + if unsafe { + GetTokenInformation(token.0, 1, bytes.as_mut_ptr().cast(), needed, &mut needed) + } == 0 + { + return Err(UNSAFE.into()); + } + if unsafe { IsValidSid(bytes[0] as Ptr) } == 0 { + return Err(UNSAFE.into()); + } + Ok(bytes) + } + fn wide(path: &Path) -> Result> { + let out: Vec<_> = path.as_os_str().encode_wide().collect(); + if out.contains(&0) { + return Err(UNSAFE.into()); + } + Ok(out.into_iter().chain(Some(0)).collect()) + } + pub fn verify(path: &Path, root: bool) -> Result<()> { + let user = user()?; + let name = wide(path)?; + let mut owner = ptr::null_mut(); + let mut dacl = ptr::null_mut(); + let mut descriptor = ptr::null_mut(); + let code = unsafe { + GetNamedSecurityInfoW( + name.as_ptr(), + 1, + 1 | 4, + &mut owner, + ptr::null_mut(), + &mut dacl, + ptr::null_mut(), + &mut descriptor, + ) + }; + // Owner and DACL point inside this allocation; only the descriptor is freed. + let descriptor = Local(descriptor); + if code != 0 || descriptor.0.is_null() || owner.is_null() || dacl.is_null() { + return Err(UNSAFE.into()); + } + let mut control = 0; + let mut revision = 0; + let mut present = 0; + let mut defaulted = 0; + if unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) } == 0 + || unsafe { + GetSecurityDescriptorDacl(descriptor.0, &mut present, &mut dacl, &mut defaulted) + } == 0 + || present == 0 + || dacl.is_null() + || unsafe { IsValidAcl(dacl) } == 0 + || unsafe { IsValidSid(owner) } == 0 + || unsafe { EqualSid(owner, user[0] as Ptr) } == 0 + || root && control & 0x1000 == 0 + { + return Err(UNSAFE.into()); + } + let mut system = Sid { + revision: 1, + count: 1, + authority: [0, 0, 0, 0, 0, 5], + sub: [18, 0], + }; + let mut admins = Sid { + revision: 1, + count: 2, + authority: [0, 0, 0, 0, 0, 5], + sub: [32, 544], + }; + let mut full_control = false; + for i in 0..unsafe { (*dacl).count } as u32 { + let mut ace = ptr::null_mut(); + if unsafe { GetAce(dacl, i, &mut ace) } == 0 || ace.is_null() { + return Err(UNSAFE.into()); + } + let header = unsafe { &*ace.cast::() }; + if header.kind != 0 || header.size < 16 || root && header.flags & 0x10 != 0 { + return Err(UNSAFE.into()); + } + let mask = unsafe { *ace.cast::().add(4).cast::() }; + let sid = unsafe { ace.cast::().add(8).cast::() }; + if unsafe { IsValidSid(sid) } == 0 + || unsafe { GetLengthSid(sid) } + 8 > header.size as u32 + { + return Err(UNSAFE.into()); + } + let is_user = unsafe { EqualSid(sid, user[0] as Ptr) } != 0; + if !is_user + && unsafe { EqualSid(sid, (&mut system as *mut Sid).cast()) } == 0 + && unsafe { EqualSid(sid, (&mut admins as *mut Sid).cast()) } == 0 + { + return Err(UNSAFE.into()); + } + // INHERIT_ONLY_ACE does not grant this object access. + full_control |= is_user + && header.flags & 0x8 == 0 + && (mask & 0x1f01ff == 0x1f01ff || mask & 0x10000000 != 0); + } + if !full_control { + return Err(UNSAFE.into()); + } + Ok(()) + } + fn well_known_sid(kind: u32) -> Result<[u32; 17]> { + // SECURITY_MAX_SID_SIZE is 68 bytes. Caller-owned, DWORD-aligned storage + // needs no LocalFree/FreeSid and remains alive while building the ACL. + let mut sid = [0u32; 17]; + let mut size = std::mem::size_of_val(&sid) as u32; + if unsafe { CreateWellKnownSid(kind, ptr::null_mut(), sid.as_mut_ptr().cast(), &mut size) } + == 0 + { + return Err(UNSAFE.into()); + } + Ok(sid) + } + fn allowed_acl(sids: &[Ptr], flags: u32) -> Result> { + let size = std::mem::size_of::() as u32 + + sids + .iter() + .map(|sid| 8 + unsafe { GetLengthSid(*sid) }) + .sum::(); + let mut buffer = vec![0u32; (size as usize).div_ceil(std::mem::size_of::())]; + let acl = buffer.as_mut_ptr().cast::(); + if unsafe { InitializeAcl(acl, size, ACL_REVISION) } == 0 { + return Err(UNSAFE.into()); + } + for sid in sids { + if unsafe { AddAccessAllowedAceEx(acl, ACL_REVISION, flags, FULL_CONTROL, *sid) } == 0 { + return Err(UNSAFE.into()); + } + } + Ok(buffer) + } + pub fn harden(path: &Path, directory: bool) -> Result<()> { + let user = user()?; + let mut system = well_known_sid(22)?; // WinLocalSystemSid + let mut admins = well_known_sid(26)?; // WinBuiltinAdministratorsSid + let mut acl = allowed_acl( + &[ + user[0] as Ptr, + system.as_mut_ptr().cast(), + admins.as_mut_ptr().cast(), + ], + if directory { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + 0 + }, + )?; + let mut name = wide(path)?; + if unsafe { + SetNamedSecurityInfoW( + name.as_mut_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + acl.as_mut_ptr().cast(), + ptr::null_mut(), + ) + } != 0 + { + return Err(UNSAFE.into()); + } + verify(path, directory) + } + #[cfg(test)] + mod tests { + use super::*; + use crate::cli_command_record::{self as record, tests::Temp, Store}; + #[link(name = "advapi32")] + unsafe extern "system" { + fn AddAccessAllowedAce(acl: *mut Acl, revision: u32, mask: u32, sid: Ptr) -> i32; + } + #[test] + fn harden_sets_only_three_full_control_aces_with_directory_inheritance() { + let t = Temp::new(); + let root = t.0.join("root"); + record::private_dir(&root).unwrap(); + let file = root.join("file"); + std::fs::write(&file, b"test").unwrap(); + harden(&file, false).unwrap(); + let user = user().unwrap(); + let mut system = well_known_sid(22).unwrap(); + let mut admins = well_known_sid(26).unwrap(); + let sids = [ + user[0] as Ptr, + system.as_mut_ptr().cast(), + admins.as_mut_ptr().cast(), + ]; + for (path, flags) in [(&root, 0x3), (&file, 0)] { + let name = wide(path).unwrap(); + let mut dacl = ptr::null_mut(); + let mut descriptor = ptr::null_mut(); + assert_eq!( + unsafe { + GetNamedSecurityInfoW( + name.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + &mut dacl, + ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let descriptor = Local(descriptor); + assert!(!dacl.is_null()); + let mut control = 0; + let mut revision = 0; + assert_ne!( + unsafe { + GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) + }, + 0 + ); + assert_ne!(control & 0x1000, 0); + assert_eq!(unsafe { (*dacl).count }, 3); + for (index, sid) in sids.iter().enumerate() { + let mut ace = ptr::null_mut(); + assert_ne!(unsafe { GetAce(dacl, index as u32, &mut ace) }, 0); + let header = unsafe { &*ace.cast::() }; + assert_eq!(header.kind, 0); + assert_eq!(header.flags, flags); + assert_eq!( + unsafe { *ace.cast::().add(4).cast::() }, + FULL_CONTROL + ); + assert_ne!(unsafe { EqualSid(ace.cast::().add(8).cast(), *sid) }, 0); + } + } + } + #[test] + fn hardened_root_and_inherited_child_are_valid_after_store_reopen() { + let t = Temp::new(); + let s = t.store(); + verify(&s.root, true).unwrap(); + let child = s.root.join("backups/child"); + std::fs::write(&child, b"test").unwrap(); + verify(&child, false).unwrap(); + let root = s.root.clone(); + drop(s); + let s = Store::open(root, vec![]).unwrap(); + verify(&s.root, true).unwrap(); + verify(&child, false).unwrap(); + } + #[test] + fn root_without_protection_is_refused_even_without_inherited_aces() { + let t = Temp::new(); + // Replace the temporary parent's whole DACL with one non-inheritable user ACE. + let user = user().unwrap(); + let size = + std::mem::size_of::() as u32 + 8 + unsafe { GetLengthSid(user[0] as Ptr) }; + let mut buffer = vec![0usize; (size as usize).div_ceil(std::mem::size_of::())]; + let parent_acl = buffer.as_mut_ptr().cast::(); + assert_ne!(unsafe { InitializeAcl(parent_acl, size, 2) }, 0); + assert_ne!( + unsafe { AddAccessAllowedAce(parent_acl, 2, 0x1f01ff, user[0] as Ptr) }, + 0 + ); + let mut parent_name = wide(&t.0).unwrap(); + assert_eq!( + unsafe { + SetNamedSecurityInfoW( + parent_name.as_mut_ptr(), + 1, + 4 | 0x80000000, + ptr::null_mut(), + ptr::null_mut(), + parent_acl, + ptr::null_mut(), + ) + }, + 0 + ); + verify(&t.0, true).unwrap(); + let mut dacl = ptr::null_mut(); + let mut descriptor = ptr::null_mut(); + assert_eq!( + unsafe { + GetNamedSecurityInfoW( + parent_name.as_ptr(), + 1, + 4, + ptr::null_mut(), + ptr::null_mut(), + &mut dacl, + ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let descriptor = Local(descriptor); + assert!(!dacl.is_null()); + assert_eq!(unsafe { (*dacl).count }, 1); + let mut ace = ptr::null_mut(); + assert_ne!(unsafe { GetAce(dacl, 0, &mut ace) }, 0); + assert_eq!(unsafe { (*ace.cast::()).flags } & 0x3, 0); + drop(descriptor); + + let root = t.0.join("root"); + record::private_dir(&root).unwrap(); + let mut name = wide(&root).unwrap(); + let mut dacl = ptr::null_mut(); + let mut descriptor = ptr::null_mut(); + assert_eq!( + unsafe { + GetNamedSecurityInfoW( + name.as_ptr(), + 1, + 4, + ptr::null_mut(), + ptr::null_mut(), + &mut dacl, + ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let descriptor = Local(descriptor); + assert!(!dacl.is_null()); + // Preserve the explicit, full-control root ACL and only remove its protection. + assert_eq!( + unsafe { + SetNamedSecurityInfoW( + name.as_mut_ptr(), + 1, + 4 | 0x20000000, + ptr::null_mut(), + ptr::null_mut(), + dacl, + ptr::null_mut(), + ) + }, + 0 + ); + drop(descriptor); + let mut checked = ptr::null_mut(); + let mut descriptor = ptr::null_mut(); + assert_eq!( + unsafe { + GetNamedSecurityInfoW( + name.as_ptr(), + 1, + 4, + ptr::null_mut(), + ptr::null_mut(), + &mut checked, + ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let descriptor = Local(descriptor); + let mut control = 0; + let mut revision = 0; + assert_ne!( + unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) }, + 0 + ); + assert_eq!(control & 0x1000, 0); + assert!(!checked.is_null()); + for i in 0..unsafe { (*checked).count } as u32 { + let mut ace = ptr::null_mut(); + assert_ne!(unsafe { GetAce(checked, i, &mut ace) }, 0); + assert_eq!(unsafe { (*ace.cast::()).flags } & 0x10, 0); + } + // All other child ACL rules pass; the root fails specifically on SE_DACL_PROTECTED. + verify(&root, false).unwrap(); + assert_eq!(verify(&root, true).unwrap_err(), UNSAFE); + } + #[test] + fn explicit_everyone_ace_is_refused_on_root_and_child() { + let t = Temp::new(); + let s = t.store(); + let child = s.root.join("backups/child"); + std::fs::write(&child, b"test").unwrap(); + for (path, root) in [(&s.root, true), (&child, false)] { + let user = user().unwrap(); + let mut everyone = well_known_sid(1).unwrap(); // WinWorldSid + let mut acl = + allowed_acl(&[user[0] as Ptr, everyone.as_mut_ptr().cast()], 0).unwrap(); + let mut name = wide(path).unwrap(); + assert_eq!( + unsafe { + SetNamedSecurityInfoW( + name.as_mut_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + acl.as_mut_ptr().cast(), + ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!(verify(path, root).unwrap_err(), UNSAFE); + } + } + #[test] + fn protected_owned_root_with_null_dacl_is_refused() { + let t = Temp::new(); + let root = t.0.join("root"); + record::private_dir(&root).unwrap(); + let mut name = wide(&root).unwrap(); + assert_eq!( + unsafe { + SetNamedSecurityInfoW( + name.as_mut_ptr(), + 1, + 4 | 0x80000000, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!(verify(&root, true).unwrap_err(), UNSAFE); + harden(&root, true).unwrap(); + } + } +} +#[cfg(any(windows, test))] +fn normalized(s: &str) -> String { + s.replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase() +} +#[cfg(any(windows, test))] +fn path_from_environment_block(block: &[u16]) -> Result>> { + let end = block + .windows(2) + .position(|pair| pair == [0, 0]) + .ok_or("machine-path-unobserved")?; + let mut start = 0; + while start < end { + let finish = block[start..end] + .iter() + .position(|unit| *unit == 0) + .map(|offset| start + offset) + .unwrap_or(end); + let variable = &block[start..finish]; + // A drive pseudo-variable such as =C:=C:\x is not an environment name. + if variable.first() != Some(&(b'=' as u16)) { + if let Some(separator) = variable.iter().position(|unit| *unit == b'=' as u16) { + if String::from_utf16(&variable[..separator]) + .is_ok_and(|name| name.eq_ignore_ascii_case("PATH")) + { + let value = String::from_utf16(&variable[separator + 1..]) + .map_err(|_| "machine-path-unobserved")?; + return Ok(Some(parts(&value))); + } + } + } + start = finish + 1; + } + Ok(None) +} +#[cfg(any(windows, test))] +fn unquoted_entry(entry: &str) -> &str { + entry + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(entry) +} +#[cfg(any(windows, test))] +fn searchable_entry(entry: &str) -> Option<&str> { + let entry = unquoted_entry(entry); + let bytes = entry.as_bytes(); + let absolute = (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/')) + || entry.starts_with("\\\\") + || entry.starts_with("//"); + // cmd searches the working directory before PATH; relative and %-literal entries + // are cwd-dependent, so probing them cannot establish a PATH conflict. + (!entry.is_empty() && !entry.contains('%') && absolute).then_some(entry) +} +#[cfg(any(windows, test))] +fn conflict_before_desktop( + entries: &[String], + desktop_dir: &str, + mut has_ocx: impl FnMut(&str) -> bool, +) -> Result { + for entry in entries { + let unquoted = unquoted_entry(entry); + if normalized(unquoted) == normalized(desktop_dir) { + return Ok(false); + } + if let Some(searchable) = searchable_entry(entry) { + if has_ocx(searchable) { + return Ok(true); + } + } + } + Err("machine-path-unobserved".into()) +} +#[cfg(any(windows, test))] +fn parts(s: &str) -> Vec { + if s.is_empty() { + Vec::new() + } else { + s.split(';').map(str::to_owned).collect() + } +} +#[cfg(any(windows, test))] +fn index(v: &[String], entry: &str) -> Result> { + let hits: Vec<_> = v + .iter() + .enumerate() + .filter(|(_, s)| normalized(s) == normalized(entry)) + .map(|(i, _)| i) + .collect(); + if hits.len() > 1 { + Err("path-entry-ambiguous".into()) + } else { + Ok(hits.first().copied()) + } +} +#[cfg(any(windows, test))] +fn entry_ok(s: &str) -> bool { + !s.is_empty() && !s.contains([';', '\0', '\r', '\n']) +} +#[cfg(any(windows, test))] +pub fn prepend(raw: &str, entry: &str, ty: &str) -> Result<(String, Windows)> { + if !entry_ok(entry) || !matches!(ty, "REG_SZ" | "REG_EXPAND_SZ") { + return Err("path-invalid".into()); + } + let mut v = parts(raw); + let found = index(&v, entry)?; + let before = found.and_then(|i| i.checked_sub(1)).map(|i| v[i].clone()); + let after = found.and_then(|i| v.get(i + 1)).cloned(); + let actual = found.map(|i| v.remove(i)).unwrap_or_else(|| entry.into()); + v.insert(0, actual); + let result = v.join(";"); + if result.encode_utf16().count() + 1 > 32_767 { + return Err("path-too-long".into()); + } + Ok(( + result, + Windows { + key: "HKCU\\Environment".into(), + value: "Path".into(), + entry: entry.into(), + value_type: ty.into(), + action: if found.is_some() { + "moved-existing" + } else { + "inserted" + } + .into(), + previous_before: before, + previous_after: after, + }, + )) +} +#[cfg(any(windows, test))] +pub fn remove(raw: &str, owned: &Windows) -> Result { + let mut v = parts(raw); + let Some(i) = index(&v, &owned.entry)? else { + return Ok(raw.into()); + }; + if owned.action == "inserted" { + v.remove(i); + return Ok(v.join(";")); + } + let actual = v.remove(i); + let before = owned + .previous_before + .as_deref() + .map(|s| index(&v, s)) + .transpose()? + .flatten(); + let after = owned + .previous_after + .as_deref() + .map(|s| index(&v, s)) + .transpose()? + .flatten(); + let position = match ( + before, + after, + owned.previous_before.is_none(), + owned.previous_after.is_none(), + ) { + (Some(b), Some(a), _, _) if b + 1 == a => a, + (Some(b), None, _, true) if b + 1 == v.len() => v.len(), + (None, Some(0), true, _) => 0, + (None, None, true, true) if v.is_empty() => 0, + _ => return Err("path-restore-ambiguous".into()), + }; + v.insert(position, actual); + Ok(v.join(";")) +} +#[cfg(any(windows, test))] +pub fn replace_owned_entry( + raw: &str, + owned: Option<&Windows>, + entry: &str, + ty: &str, +) -> Result<(String, Windows)> { + if let Some(old) = owned { + if normalized(&old.entry) == normalized(entry) { + if index(&parts(raw), entry)?.is_none() { + return prepend(raw, entry, ty); + } + let (out, _) = prepend(raw, entry, ty)?; + let mut retained = old.clone(); + retained.value_type = ty.into(); + return Ok((out, retained)); + } + prepend(&remove(raw, old)?, entry, ty) + } else { + prepend(raw, entry, ty) + } +} +#[cfg(windows)] +pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { + if debug { + return Err("development-launch".into()); + } + let real = std::fs::canonicalize(exe).map_err(|_| "bundle-unavailable")?; + let parent = real.parent().ok_or("unpackaged-launch")?; + // current_exe is already absolute. Do not persist canonical Windows \\?\ extended syntax. + let app = exe.to_str().ok_or("path-not-utf8")?; + let cli = exe.parent().ok_or("unpackaged-launch")?.join("ocx.exe"); + let raw = cli.to_str().ok_or("path-not-utf8")?; + let canonical = real + .to_string_lossy() + .to_ascii_lowercase() + .replace('/', "\\"); + let lower = app.to_ascii_lowercase().replace('/', "\\"); + if lower.contains("\\target\\") + || lower.contains("\\temp\\") + || lower.contains("\\tmp\\") + || canonical.contains("\\target\\") + || canonical.contains("\\temp\\") + || canonical.contains("\\tmp\\") + || !std::fs::symlink_metadata(parent.join("ocx.exe")) + .is_ok_and(|m| m.is_file() && !m.file_type().is_symlink()) + { + return Err("unpackaged-launch".into()); + } + if !entry_ok(raw) || !exe.is_absolute() { + return Err("path-invalid".into()); + } + Ok(Bundle { + platform: "win32".into(), + kind: "windows-install".into(), + app_executable: app.into(), + cli_executable: raw.into(), + version: version.into(), + }) +} +#[cfg(windows)] +mod os { + use super::*; + use winreg::{ + enums::{ + HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_EXPAND_SZ, REG_SZ, + }, + RegKey, RegValue, + }; + pub fn raw() -> Result> { + let key = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey_with_flags("Environment", KEY_READ) + .map_err(|_| "registry-read-failed")?; + match key.get_raw_value("Path") { + Ok(v) if matches!(v.vtype, REG_SZ | REG_EXPAND_SZ) => Ok(Some(v)), + Ok(_) => Err("path-type-unsupported".into()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err("registry-read-failed".into()), + } + } + pub fn decode(b: &[u8]) -> Result { + if !b.len().is_multiple_of(2) { + return Err("path-invalid-utf16".into()); + } + let mut words: Vec = b + .as_chunks::<2>() + .0 + .iter() + .map(|c| u16::from_le_bytes(*c)) + .collect(); + if words.pop() != Some(0) || words.contains(&0) { + return Err("path-invalid-utf16".into()); + } + String::from_utf16(&words).map_err(|_| "path-invalid-utf16".into()) + } + pub fn encode(s: &str) -> Vec { + s.encode_utf16() + .chain(Some(0)) + .flat_map(u16::to_le_bytes) + .collect() + } + pub fn type_name(v: &RegValue) -> &'static str { + if v.vtype == REG_EXPAND_SZ { + "REG_EXPAND_SZ" + } else { + "REG_SZ" + } + } + pub fn write(c: &Change) -> Result<()> { + let key = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey_with_flags("Environment", KEY_SET_VALUE) + .map_err(|_| "registry-write-failed")?; + match &c.after { + Some(b) => { + decode(b)?; + key.set_raw_value( + "Path", + &RegValue { + bytes: b.clone(), + vtype: if c.kind == "registry-expand" { + REG_EXPAND_SZ + } else { + REG_SZ + }, + }, + ) + .map_err(|_| "registry-write-failed".into()) + } + None => match key.delete_value("Path") { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("registry-write-failed".into()), + }, + } + } + fn legacy_machine_conflict() -> Result { + let k = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey_with_flags( + "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", + KEY_READ, + ) + .map_err(|_| "machine-path-unobserved")?; + let v = match k.get_raw_value("Path") { + Ok(v) => v, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(_) => return Err("machine-path-unobserved".into()), + }; + let raw = decode(&v.bytes)?; + // Preserve raw text in storage; expansion below is observe-only for conflict detection. + for p in parts(&raw) { + let expanded = expand(&p)?; + for name in ["ocx.exe", "ocx.com", "ocx.cmd", "ocx.bat"] { + if Path::new(expanded.trim_matches('"')).join(name).is_file() { + return Ok(true); + } + } + } + Ok(false) + } + fn fresh_logon_path() -> Result>> { + use std::{ffi::c_void, ptr}; + type Ptr = *mut c_void; + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: Ptr, access: u32, token: *mut Ptr) -> i32; + } + #[link(name = "userenv")] + unsafe extern "system" { + fn CreateEnvironmentBlock(block: *mut Ptr, token: Ptr, inherit: i32) -> i32; + fn DestroyEnvironmentBlock(block: Ptr) -> i32; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> Ptr; + fn CloseHandle(handle: Ptr) -> i32; + } + struct Token(Ptr); + impl Drop for Token { + fn drop(&mut self) { + unsafe { CloseHandle(self.0) }; + } + } + struct Block(Ptr); + impl Drop for Block { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { DestroyEnvironmentBlock(self.0) }; + } + } + } + let mut token = ptr::null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), 0x8 | 0x2, &mut token) } == 0 { + return Err("machine-path-unobserved".into()); + } + let token = Token(token); + let mut block = ptr::null_mut(); + let created = unsafe { CreateEnvironmentBlock(&mut block, token.0, 0) }; + let block = Block(block); + if created == 0 || block.0.is_null() { + return Err("machine-path-unobserved".into()); + } + let mut words = Vec::new(); + for index in 0..(1 << 20) { + let unit = unsafe { *block.0.cast::().add(index) }; + words.push(unit); + if unit == 0 && (index == 0 || words[index - 1] == 0) { + if index == 0 { + words.push(0); + } + return path_from_environment_block(&words); + } + } + Err("machine-path-unobserved".into()) + } + fn has_ocx(dir: &str) -> bool { + ["ocx.exe", "ocx.com", "ocx.cmd", "ocx.bat"] + .iter() + .any(|name| Path::new(dir).join(name).is_file()) + } + pub fn machine_conflict(desktop_dir: &str) -> Result { + match fresh_logon_path() { + Ok(Some(entries)) => conflict_before_desktop(&entries, desktop_dir, has_ocx), + Err(_) | Ok(None) => match legacy_machine_conflict() { + Ok(false) => Ok(false), + Ok(true) => Err("machine-path-unobserved".into()), + Err(e) => Err(e), + }, + } + } + fn expand(s: &str) -> Result { + #[link(name = "kernel32")] + unsafe extern "system" { + fn ExpandEnvironmentStringsW(src: *const u16, dst: *mut u16, size: u32) -> u32; + } + let src: Vec = s.encode_utf16().chain(Some(0)).collect(); + let mut dst = vec![0u16; 32_767]; + let n = + unsafe { ExpandEnvironmentStringsW(src.as_ptr(), dst.as_mut_ptr(), dst.len() as u32) }; + if n == 0 || n as usize > dst.len() { + return Err("machine-path-unobserved".into()); + } + let out = + String::from_utf16(&dst[..n as usize - 1]).map_err(|_| "machine-path-unobserved")?; + if out.contains('%') { + return Err("machine-path-unobserved".into()); + } + Ok(out) + } + pub fn broadcast() -> Result<()> { + #[link(name = "user32")] + unsafe extern "system" { + fn SendMessageTimeoutW( + hwnd: *mut std::ffi::c_void, + msg: u32, + wparam: usize, + lparam: isize, + flags: u32, + timeout: u32, + result: *mut usize, + ) -> isize; + } + let environment: Vec = "Environment".encode_utf16().chain(Some(0)).collect(); + let mut result = 0usize; + // HWND_BROADCAST, WM_SETTINGCHANGE, SMTO_ABORTIFHUNG; bounded per recipient. + if unsafe { + SendMessageTimeoutW( + 0xffffusize as *mut _, + 0x001a, + 0, + environment.as_ptr() as isize, + 0x0002, + 1000, + &mut result, + ) + } == 0 + { + Err("environment-broadcast-failed".into()) + } else { + Ok(()) + } + } +} +#[cfg(windows)] +pub fn read_change(c: &Change) -> Result>> { + let found = os::raw()?; + if let Some(v) = &found { + if (c.kind == "registry-expand") != (os::type_name(v) == "REG_EXPAND_SZ") { + return Err("path-type-changed".into()); + } + } + Ok(found.map(|v| v.bytes)) +} +#[cfg(not(windows))] +pub fn read_change(_: &Change) -> Result>> { + Err("registry-unsupported".into()) +} +#[cfg(windows)] +pub fn apply_change(c: &Change) -> Result<()> { + if read_change(c)? != c.before { + return Err("concurrent-edit".into()); + } + os::write(c) +} +#[cfg(windows)] +pub fn machine_conflict(desktop_dir: &str) -> Result { + os::machine_conflict(desktop_dir) +} +#[cfg(not(windows))] +pub fn apply_change(_: &Change) -> Result<()> { + Err("registry-unsupported".into()) +} +#[cfg(windows)] +pub fn plan( + current: &Record, + bundle: Bundle, + remove_owned: bool, +) -> Result<(Record, Vec, Vec)> { + let old = os::raw()?; + let ty = old.as_ref().map(os::type_name).unwrap_or("REG_EXPAND_SZ"); + let raw = old + .as_ref() + .map(|v| os::decode(&v.bytes)) + .transpose()? + .unwrap_or_default(); + let mut next = current.clone(); + let issues = Vec::new(); + let result = if remove_owned { + if let Some(owned) = ¤t.windows { + remove(&raw, owned)? + } else { + return Ok((next, Vec::new(), issues)); + } + } else { + let entry = Path::new(&bundle.cli_executable) + .parent() + .and_then(|p| p.to_str()) + .ok_or("path-invalid")?; + let (s, owned) = replace_owned_entry(&raw, current.windows.as_ref(), entry, ty)?; + next.bundle = Some(bundle); + next.windows = Some(owned); + s + }; + if remove_owned { + next.windows = None; + } + next.posix = None; + let after = if old.is_none() && result.is_empty() { + None + } else { + Some(os::encode(&result)) + }; + let before = old.map(|v| v.bytes); + let changes = if before == after { + Vec::new() + } else { + vec![Change { + kind: if ty == "REG_EXPAND_SZ" { + "registry-expand" + } else { + "registry-sz" + } + .into(), + path: "HKCU\\Environment\\Path".into(), + before, + after, + mode: 0, + backup_path: None, + }] + }; + Ok((next, changes, issues)) +} +#[cfg(windows)] +pub fn notify() -> Result<()> { + os::broadcast() +} +#[cfg(not(windows))] +pub fn notify() -> Result<()> { + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + fn environment_block(variables: &[&str]) -> Vec { + let mut block = Vec::new(); + for variable in variables { + block.extend(variable.encode_utf16()); + block.push(0); + } + block.push(0); + if variables.is_empty() { + block.push(0); + } + block + } + #[test] + fn fresh_logon_environment_block_path_parsing() { + let block = environment_block(&["=C:=C:\\x", "TEMP=C:\\tmp", "Path=C:\\one;;C:\\two;"]); + assert_eq!( + path_from_environment_block(&block).unwrap(), + Some(vec![ + "C:\\one".into(), + "".into(), + "C:\\two".into(), + "".into() + ]) + ); + assert_eq!( + path_from_environment_block(&environment_block(&["PATH=C:\\upper"])).unwrap(), + Some(vec!["C:\\upper".into()]) + ); + assert_eq!( + path_from_environment_block(&environment_block(&["=C:=C:\\x", "TEMP=x"])).unwrap(), + None + ); + assert_eq!( + path_from_environment_block(&environment_block(&["Path="])).unwrap(), + Some(vec![]) + ); + assert_eq!(path_from_environment_block(&[0, 0]).unwrap(), None); + assert_eq!( + path_from_environment_block(&[]).unwrap_err(), + "machine-path-unobserved" + ); + assert_eq!( + path_from_environment_block(&"PATH=x\0".encode_utf16().collect::>()) + .unwrap_err(), + "machine-path-unobserved" + ); + let mut invalid = environment_block(&["PATH="]); + invalid.insert(5, 0xd800); + assert_eq!( + path_from_environment_block(&invalid).unwrap_err(), + "machine-path-unobserved" + ); + } + #[test] + fn fresh_logon_conflict_respects_desktop_order() { + let desktop = r"C:\Program Files\OpenCodex"; + let entries = [ + r"C:\Windows\system32", + "%NVM_HOME%", + "%NVM_SYMLINK%", + desktop, + r"C:\nvm4w\nodejs", + ]; + let entries: Vec = entries.iter().map(|entry| (*entry).into()).collect(); + assert_eq!( + conflict_before_desktop(&entries, desktop, |dir| dir == r"C:\nvm4w\nodejs"), + Ok(false) + ); + let before = vec![r"C:\nvm4w\nodejs".into(), desktop.into()]; + assert_eq!( + conflict_before_desktop(&before, desktop, |dir| dir == r"C:\nvm4w\nodejs"), + Ok(true) + ); + let after = vec![desktop.into(), r"C:\nvm4w\nodejs".into()]; + assert_eq!( + conflict_before_desktop(&after, desktop, |dir| dir == r"C:\nvm4w\nodejs"), + Ok(false) + ); + assert_eq!( + conflict_before_desktop(&[r"C:\other".into()], desktop, |_| false).unwrap_err(), + "machine-path-unobserved" + ); + } + #[test] + fn fresh_logon_conflict_ignores_cwd_dependent_entries() { + let desktop = r"C:\Program Files\OpenCodex"; + let entries = vec![ + "bin".into(), + "".into(), + "%NVM_HOME%".into(), + r"C:\%NVM_HOME%\bin".into(), + r#""c:/PROGRAM FILES/OpenCodex/""#.into(), + ]; + let mut probed = Vec::new(); + assert_eq!( + conflict_before_desktop(&entries, desktop, |dir| { + probed.push(dir.to_owned()); + true + }), + Ok(false) + ); + assert!(probed.is_empty()); + } + #[test] + fn prepend_preserves_raw_expansions_type_and_empty_entries() { + let raw = r"%APPDATA%\npm;;C:\Tools;"; + for ty in ["REG_SZ", "REG_EXPAND_SZ"] { + let (out, owned) = prepend(raw, r"C:\Program Files\OpenCodex", ty).unwrap(); + assert_eq!(out, format!(r"C:\Program Files\OpenCodex;{raw}")); + assert_eq!(owned.value_type, ty); + assert_eq!(owned.action, "inserted"); + assert_eq!(remove(&out, &owned).unwrap(), raw); + } + } + #[test] + fn moved_existing_entry_is_restored_and_same_entry_repair_keeps_ownership() { + let raw = r"A;C:\Desktop;B"; + let (out, owned) = prepend(raw, r"c:/desktop/", "REG_SZ").unwrap(); + assert_eq!(owned.action, "moved-existing"); + let (twice, retained) = + replace_owned_entry(&out, Some(&owned), r"C:\Desktop", "REG_SZ").unwrap(); + assert_eq!(twice, out); + assert_eq!(retained, owned); + assert_eq!(remove(&out, &owned).unwrap(), raw); + } + #[test] + fn removal_preserves_concurrent_unrelated_entries_and_ambiguity() { + let (out, owned) = prepend(r"A;B", r"C:\Desktop", "REG_EXPAND_SZ").unwrap(); + assert_eq!(remove(&format!("{out};NEW"), &owned).unwrap(), "A;B;NEW"); + let (out, moved) = prepend(r"A;C:\Desktop;B", r"C:\Desktop", "REG_SZ").unwrap(); + assert_eq!( + remove(&out.replace("A;B", "A;NEW;B"), &moved).unwrap_err(), + "path-restore-ambiguous" + ); + } + #[test] + fn replacing_owned_entry_removes_only_the_previous_insertion() { + let (out, old) = prepend("A;B", r"C:\Old", "REG_SZ").unwrap(); + let (new, owned) = replace_owned_entry(&out, Some(&old), r"C:\New", "REG_SZ").unwrap(); + assert_eq!(new, r"C:\New;A;B"); + assert_eq!(remove(&new, &owned).unwrap(), "A;B"); + assert_eq!(remove("A;B", &old).unwrap(), "A;B"); + let (_, moved) = prepend(r"A;C:\Old;B", r"C:\Old", "REG_SZ").unwrap(); + let (repaired, inserted) = + replace_owned_entry("A;B", Some(&moved), r"C:\Old", "REG_SZ").unwrap(); + assert_eq!(inserted.action, "inserted"); + assert_eq!(remove(&repaired, &inserted).unwrap(), "A;B"); + } + #[test] + fn duplicate_invalid_and_oversize_entries_are_refused() { + assert!(prepend(r"C:\Desktop;c:/desktop/", r"C:\Desktop", "REG_SZ").is_err()); + assert!(prepend("A", "bad;entry", "REG_SZ").is_err()); + assert!(prepend("A", r"C:\Desktop", "REG_BINARY").is_err()); + assert!(prepend(&"X".repeat(32_767), r"C:\Desktop", "REG_SZ").is_err()); + } +} diff --git a/desktop/src-tauri/src/first_run.rs b/desktop/src-tauri/src/first_run.rs index cf0c5b9e87c..9f1532e79c3 100644 --- a/desktop/src-tauri/src/first_run.rs +++ b/desktop/src-tauri/src/first_run.rs @@ -85,6 +85,11 @@ pub fn apply_start_at_login_default(app: &AppHandle) -> StartAtLogin { /// It runs once, behind its own marker, and only where autostart is already on. It never turns the /// setting on and never turns it off; the worst case is an entry that keeps its old arguments and a /// login launch that shows its window, which is the visible failure rather than the silent one. +/// +/// On macOS the marker lives in a config directory every copy of the app shares, while the plugin +/// rewrites whatever plist exists. The rewrite is therefore limited to this copy's own entry in the +/// exact shape the plugin writes; another or a missing copy, or any key the rewrite would drop, is +/// left untouched and unmarked (#6972). pub fn adopt_launch_origin_argument(app: &AppHandle) { let Ok(dir) = app.path().app_config_dir() else { return; @@ -102,6 +107,23 @@ pub fn adopt_launch_origin_argument(app: &AppHandle) { // that is already enabled, so retrying after a transient registry, LaunchAgent or desktop-file // error is free — and claiming the marker first would suppress the migration permanently and // leave a login launch showing its window forever. + #[cfg(target_os = "macos")] + { + use crate::login_registration::Migration; + let Ok(home) = app.path().home_dir() else { + return; + }; + match crate::login_registration::judge_migration(&home) { + Migration::Rewrite => { + if crate::login_autostart::enable(app).is_err() { + return; + } + } + Migration::Absent => {} + Migration::Leave => return, + } + } + #[cfg(not(target_os = "macos"))] match app.autolaunch().is_enabled() { Ok(true) => { if crate::login_autostart::enable(app).is_err() { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0dcd286717d..d4e3dec7e32 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,5 +1,10 @@ mod auth; mod claim; +mod cli_command; +#[cfg(unix)] +mod cli_command_posix; +mod cli_command_record; +mod cli_command_windows; #[cfg(target_os = "macos")] mod companion_query; mod companion_usage; @@ -10,6 +15,9 @@ mod formatting; mod identity; mod logging; mod login_autostart; +// The judgment is portable so Linux CI runs its fixture parity tests; only macOS reads launchd. +#[cfg(any(target_os = "macos", test))] +mod login_registration; #[cfg(any(target_os = "windows", test))] mod windows_autostart_command; // macOS only: it exists to replace one item in a menu no other platform installs. Compiling it @@ -30,6 +38,8 @@ mod popup; mod popup; #[cfg(target_os = "macos")] mod provider_icons; +#[cfg(target_os = "macos")] +mod tray_login; // The macOS build selects native_tray.rs as the popup module; compile the portable popup // module's tests on macOS too so its navigation rules run on the maintainers' platform. #[cfg(all(test, target_os = "macos"))] @@ -213,6 +223,43 @@ fn decide_takeover(app: tauri::AppHandle, approved: bool) { } } +#[tauri::command] +fn cli_status( + window: tauri::WebviewWindow, + app: tauri::AppHandle, +) -> Result { + window::require_cli_page(&window)?; + Ok(cli_command::status(&app)) +} + +#[tauri::command] +async fn cli_set_enabled( + window: tauri::WebviewWindow, + app: tauri::AppHandle, + enabled: bool, +) -> Result { + window::require_cli_page(&window)?; + cli_command::set_enabled(app, enabled).await +} + +#[tauri::command] +async fn cli_install( + window: tauri::WebviewWindow, + app: tauri::AppHandle, +) -> Result { + window::require_cli_page(&window)?; + cli_command::install(app).await +} + +#[tauri::command] +async fn cli_remove( + window: tauri::WebviewWindow, + app: tauri::AppHandle, +) -> Result { + window::require_cli_page(&window)?; + cli_command::remove(app).await +} + #[tauri::command] async fn update_status( window: tauri::WebviewWindow, @@ -247,7 +294,7 @@ async fn update_install( #[tauri::command] fn return_to_dashboard(window: tauri::WebviewWindow, app: tauri::AppHandle) -> Result<(), String> { - window::require_update_page(&window)?; + window::require_local_settings_page(&window)?; startup::return_to_dashboard(&app) } @@ -291,6 +338,10 @@ pub fn run() { startup_phases, retry_startup, decide_takeover, + cli_status, + cli_set_enabled, + cli_install, + cli_remove, update_status, update_check, update_install, @@ -298,6 +349,7 @@ pub fn run() { ]) .setup(|app| { app.manage(AppState::new()); + app.manage(cli_command::State::default()); app.manage(updater::PendingUpdate(Mutex::new(None))); app.manage(updater::DesktopUpdateState::new( app.package_info().version.to_string(), diff --git a/desktop/src-tauri/src/login_registration.rs b/desktop/src-tauri/src/login_registration.rs new file mode 100644 index 00000000000..43ea2ae71e2 --- /dev/null +++ b/desktop/src-tauri/src/login_registration.rs @@ -0,0 +1,1064 @@ +//! Start at Login on macOS, judged the way Startup safety judges it. +//! +//! The autostart plugin answers "is Start at Login on?" with whether +//! `~/Library/LaunchAgents/OpenCodex.plist` exists. Startup safety +//! (`src/service/desktop-startup.ts`, `macLoginRegistration`) asks more: the plist must have the +//! shape this app writes, launchd must not have the job disabled, the registered app must be this +//! copy, and a loaded job must have been loaded from this copy and this plist. When the two +//! disagreed the tray showed a checked item while the Startup page reported Start at Login as +//! unverified (#6972). +//! +//! [`classify`] is a step-for-step port of that judgment over an injected [`Probe`], so the tray +//! can be tested without launchd. The cases in `tests/fixtures/desktop-login-registration-cases.json` +//! are run against both implementations; a change to either side that is not mirrored in the other +//! fails one of the two suites. Only [`LoginState::On`] is shown checked: every other state is a +//! reason the registration could not be credited, and none of them may read as on. + +use serde_json::Value; +use std::io; +use std::path::{Path, PathBuf}; + +const SERVICE: &str = "OpenCodex"; +const APP_SUFFIX: &str = "/Contents/MacOS/opencodex-desktop"; +const AUTOSTART_ARGUMENT: &str = "--autostart"; +/// `managed_by` of a job Background Task Management resubmitted through smd (#7019). +const SERVICE_MANAGEMENT: &str = "com.apple.xpc.ServiceManagement"; +/// System Settings → General → Login Items & Extensions, where a disabled job is turned back on. +#[cfg(target_os = "macos")] +pub const LOGIN_ITEMS_SETTINGS: &str = + "x-apple.systempreferences:com.apple.LoginItems-Settings.extension"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoginState { + /// This copy's registration is enabled and either loaded from this copy or valid for the next + /// login. The only state Startup safety credits, and the only one shown checked. + On, + /// No LaunchAgent plist. + Off, + /// This copy is registered but launchd has the job disabled (System Settings or + /// `launchctl disable`). Rewriting the plist does not clear that, so the item opens Login + /// Items settings instead. + DisabledBySystem, + /// The registration, or the job launchd loaded, belongs to another OpenCodex copy. The tray + /// never rewrites it. + OtherCopy, + /// The registration names an app path that does not exist. It is still not this copy's. + MissingCopy, + /// The evidence could not be read, was malformed, timed out or changed while it was read. + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClickAction { + Disable, + Enable, + OpenLoginItems, + Nothing, +} + +impl LoginState { + pub fn checked(self) -> bool { + self == Self::On + } + + pub fn label(self) -> &'static str { + match self { + Self::On | Self::Off => "Start at Login", + Self::DisabledBySystem => "Start at Login (disabled by macOS)", + Self::OtherCopy => "Start at Login (another OpenCodex copy)", + Self::MissingCopy => "Start at Login (registered app is missing)", + Self::Unknown => "Start at Login (status unknown)", + } + } + + pub fn click_action(self) -> ClickAction { + match self { + Self::On => ClickAction::Disable, + Self::Off => ClickAction::Enable, + Self::DisabledBySystem => ClickAction::OpenLoginItems, + // A missing path may be a copy being replaced or on a volume that is not mounted, so it + // is no more this copy's to take over than an existing one. + Self::OtherCopy | Self::MissingCopy | Self::Unknown => ClickAction::Nothing, + } + } + + pub fn clickable(self) -> bool { + self.click_action() != ClickAction::Nothing + } +} + +/// A failed command. `Exit` is an ordinary non-zero exit; everything else (spawn failure, +/// timeout, signal, oversized or non-UTF-8 output) is `Failed`, which never counts as absence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunError { + Exit { status: i32, stderr: String }, + Failed, +} + +/// The filesystem and process reads the judgment needs, injectable for tests. +pub trait Probe { + /// An identity of the path itself (not its target) that changes when it is replaced or edited. + fn identity(&self, path: &Path) -> io::Result; + fn realpath(&self, path: &Path) -> io::Result; + fn executable(&self, path: &Path) -> io::Result<()>; + /// Trimmed stdout of a successful run. + fn run(&self, program: &str, args: &[&str]) -> Result; +} + +/// JavaScript's `\s`, so trimming and the regex ports below match the TypeScript judgment. +fn js_space(c: char) -> bool { + matches!( + c, + '\t' | '\n' | '\u{0b}' | '\u{0c}' | '\r' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +pub fn js_trim(text: &str) -> &str { + text.trim_matches(js_space) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Enabled { + Yes, + Disabled, + Unknown, +} + +/// `/"OpenCodex"\s*=>\s*(enabled|false|disabled|true)(?=\s*(?:\r?\n|[,;}]|$))/`, first match. +fn override_value(text: &str) -> Option<&'static str> { + let needle = "\"OpenCodex\""; + let mut from = 0; + while let Some(found) = text[from..].find(needle) { + let start = from + found; + from = start + needle.len(); + let rest = text[from..].trim_start_matches(js_space); + let Some(rest) = rest.strip_prefix("=>") else { + continue; + }; + let rest = rest.trim_start_matches(js_space); + for word in ["enabled", "false", "disabled", "true"] { + let Some(after) = rest.strip_prefix(word) else { + continue; + }; + let spaces = after.len() - after.trim_start_matches(js_space).len(); + let (gap, next) = after.split_at(spaces); + if gap.contains('\n') || next.is_empty() || next.starts_with([',', ';', '}']) { + return Some(word); + } + } + } + None +} + +/// The print-disabled half of `loginEnabled` in `macLoginRegistration`. +fn enabled(disabled: &str, plist_disabled: Option) -> Enabled { + let body = js_trim(disabled); + if !(body.starts_with("disabled services = {") && body.ends_with('}')) { + return Enabled::Unknown; + } + match disabled.matches("\"OpenCodex\"").count() { + // launchd's override is absent, so the plist's own Disabled key decides. auto-launch never + // writes one; a plist that disables itself was edited by something else. + 0 if plist_disabled == Some(true) => Enabled::Unknown, + 0 => Enabled::Yes, + 1 => match override_value(disabled) { + Some("enabled" | "false") => Enabled::Yes, + Some(_) => Enabled::Disabled, + None => Enabled::Unknown, + }, + _ => Enabled::Unknown, + } +} + +/// ECMAScript line terminators: what `^`, `$` and `.` honour under the `m` flag. +fn js_line_end(c: char) -> bool { + matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') +} + +/// `/^\s*key(.+)$/m`, first match. +fn line_value<'a>(text: &'a str, key: &str) -> Option<&'a str> { + text.split(js_line_end).find_map(|line| { + line.trim_start_matches(js_space) + .strip_prefix(key) + .filter(|value| !value.is_empty()) + }) +} + +/// `/^\(submitted by smd\.\d+\)$/`: launchctl's `path` for a job smd resubmitted. +fn smd_submitted(path: &str) -> bool { + path.strip_prefix("(submitted by smd.") + .and_then(|rest| rest.strip_suffix(')')) + .is_some_and(|pid| !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit())) +} + +/// The registered program when the plist has the shape auto-launch writes for this app. +fn registered_app(plist: &Value) -> Option<(&str, Option)> { + let args = plist.get("ProgramArguments")?.as_array()?; + let app = args.first()?.as_str()?; + let disabled = match plist.get("Disabled") { + None => None, + Some(Value::Bool(value)) => Some(*value), + Some(_) => return None, + }; + let shaped = plist.get("Label").and_then(Value::as_str) == Some(SERVICE) + && plist.get("RunAtLoad") == Some(&Value::Bool(true)) + && args.len() == 2 + && args[1].as_str() == Some(AUTOSTART_ARGUMENT) + && app.ends_with(APP_SUFFIX) + && plist + .get("Program") + .is_none_or(|program| program.as_str() == Some(app)); + shaped.then_some((app, disabled)) +} + +#[derive(Debug, PartialEq, Eq)] +struct Capture { + app: PathBuf, + proxy: PathBuf, + registration: PathBuf, + plist_identity: String, + target_identity: String, + definition: String, + app_identity: String, + proxy_identity: String, + disabled: String, + enabled: Enabled, +} + +fn unknown(_: T) -> LoginState { + LoginState::Unknown +} + +fn capture( + probe: &dyn Probe, + plist: &Path, + domain: &str, + current_app: &Path, +) -> Result { + let plist_identity = probe.identity(plist).map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + LoginState::Off + } else { + LoginState::Unknown + } + })?; + let registration = probe.realpath(plist).map_err(unknown)?; + let target_identity = probe.identity(®istration).map_err(unknown)?; + let plist_arg = plist.to_str().ok_or(LoginState::Unknown)?; + let definition = probe + .run( + "/usr/bin/plutil", + &["-convert", "json", "-o", "-", plist_arg], + ) + .map_err(unknown)?; + let parsed: Value = serde_json::from_str(&definition).map_err(unknown)?; + let (registered, plist_disabled) = registered_app(&parsed).ok_or(LoginState::Unknown)?; + let registered = Path::new(registered); + let app = probe.realpath(registered).map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + LoginState::MissingCopy + } else { + LoginState::Unknown + } + })?; + if app != current_app { + return Err(LoginState::OtherCopy); + } + let sibling = app.parent().ok_or(LoginState::Unknown)?.join("ocx"); + let proxy = probe.realpath(&sibling).map_err(unknown)?; + probe.executable(&app).map_err(unknown)?; + probe.executable(&proxy).map_err(unknown)?; + let app_identity = probe.identity(&app).map_err(unknown)?; + let proxy_identity = probe.identity(&proxy).map_err(unknown)?; + let disabled = probe + .run("/bin/launchctl", &["print-disabled", domain]) + .map_err(unknown)?; + let enabled = enabled(&disabled, plist_disabled); + // Every path and identity is read again: a registration replaced during the probes is not the + // registration that was judged. + let stable = probe.realpath(plist).ok().as_ref() == Some(®istration) + && probe.identity(plist).ok().as_ref() == Some(&plist_identity) + && probe.identity(®istration).ok().as_ref() == Some(&target_identity) + && probe.realpath(registered).ok().as_ref() == Some(&app) + && probe.realpath(&sibling).ok().as_ref() == Some(&proxy) + && probe.identity(&app).ok().as_ref() == Some(&app_identity) + && probe.identity(&proxy).ok().as_ref() == Some(&proxy_identity); + if !stable { + return Err(LoginState::Unknown); + } + Ok(Capture { + app, + proxy, + registration, + plist_identity, + target_identity, + definition, + app_identity, + proxy_identity, + disabled, + enabled, + }) +} + +/// Judge this copy's Start at Login registration. `current_app` must already be resolved. +pub fn classify(probe: &dyn Probe, home: &Path, uid: u32, current_app: &Path) -> LoginState { + let plist = home + .join("Library") + .join("LaunchAgents") + .join(format!("{SERVICE}.plist")); + let domain = format!("gui/{uid}"); + let first = match capture(probe, &plist, &domain, current_app) { + Ok(first) => first, + Err(state) => return state, + }; + match first.enabled { + Enabled::Yes => {} + Enabled::Disabled => return LoginState::DisabledBySystem, + Enabled::Unknown => return LoginState::Unknown, + } + let target = format!("{domain}/{SERVICE}"); + match probe.run("/bin/launchctl", &["print", &target]) { + Ok(loaded) => { + let Some(program) = line_value(&loaded, "program = ") else { + return LoginState::Unknown; + }; + match probe.realpath(Path::new(program)) { + Ok(program) if program == first.app => {} + Ok(_) => return LoginState::OtherCopy, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return LoginState::OtherCopy + } + Err(_) => return LoginState::Unknown, + } + let Some(path) = line_value(&loaded, "path = ") else { + return LoginState::Unknown; + }; + // macOS 13+ Background Task Management can resubmit the user LaunchAgent through + // smd; launchctl then names the submitter instead of the plist (#7019). Credit it like + // an unloaded job: the whole registration is read again and must not have changed. + if smd_submitted(path) + && line_value(&loaded, "managed_by = ") == Some(SERVICE_MANAGEMENT) + { + return match capture(probe, &plist, &domain, current_app) { + Ok(last) if last == first => LoginState::On, + _ => LoginState::Unknown, + }; + } + match probe.realpath(Path::new(path)) { + Ok(path) if path == first.registration => LoginState::On, + _ => LoginState::Unknown, + } + } + // launchctl's own service-not-found answer is absence; any other failure is unverified. + Err(RunError::Exit { + status: 113, + stderr, + }) => { + let missing = + format!("Could not find service \"{SERVICE}\" in domain for user gui: {uid}"); + let stderr = js_trim(&stderr); + if stderr != missing && stderr != format!("Bad request.\n{missing}") { + return LoginState::Unknown; + } + // An unloaded job leaves no durable evidence, so the whole registration is read again + // and must not have changed. + match capture(probe, &plist, &domain, current_app) { + Ok(last) if last == first => LoginState::On, + _ => LoginState::Unknown, + } + } + Err(_) => LoginState::Unknown, + } +} + +/// What the once-per-installation launch-origin migration in `first_run.rs` may do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Migration { + /// No LaunchAgent: nothing to migrate. + Absent, + /// This copy's own enabled entry, in the shape auto-launch writes with or without + /// `--autostart`: rewriting it changes only the arguments. + Rewrite, + /// Anything else: another or a missing copy, a `Program` override, a definition that disables + /// itself, an edited shape or unreadable evidence. Rewriting would change more than arguments. + Leave, +} + +pub fn migration(probe: &dyn Probe, home: &Path, current_app: &Path) -> Migration { + let plist = home + .join("Library") + .join("LaunchAgents") + .join(format!("{SERVICE}.plist")); + let plist_identity = match probe.identity(&plist) { + Ok(identity) => identity, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Migration::Absent, + Err(_) => return Migration::Leave, + }; + let Ok(registration) = probe.realpath(&plist) else { + return Migration::Leave; + }; + let Ok(target_identity) = probe.identity(®istration) else { + return Migration::Leave; + }; + let Some(plist_arg) = plist.to_str() else { + return Migration::Leave; + }; + let Ok(definition) = probe.run( + "/usr/bin/plutil", + &["-convert", "json", "-o", "-", plist_arg], + ) else { + return Migration::Leave; + }; + let Ok(parsed) = serde_json::from_str::(&definition) else { + return Migration::Leave; + }; + let Some(args) = parsed.get("ProgramArguments").and_then(Value::as_array) else { + return Migration::Leave; + }; + let Some(app) = args.first().and_then(Value::as_str) else { + return Migration::Leave; + }; + let arguments = match args.len() { + 1 => true, + 2 => args[1].as_str() == Some(AUTOSTART_ARGUMENT), + _ => false, + }; + // The plugin rewrites the whole file with exactly these three keys, so any other key — + // `Program`, `Disabled`, `KeepAlive`, environment — would be dropped by the rewrite. + let keys = parsed.as_object().is_some_and(|plist| { + plist.len() == 3 + && ["Label", "ProgramArguments", "RunAtLoad"] + .iter() + .all(|key| plist.contains_key(*key)) + }); + let own_shape = arguments + && keys + && parsed.get("Label").and_then(Value::as_str) == Some(SERVICE) + && parsed.get("RunAtLoad") == Some(&Value::Bool(true)) + && app.ends_with(APP_SUFFIX); + if !own_shape { + return Migration::Leave; + } + let Ok(resolved) = probe.realpath(Path::new(app)) else { + return Migration::Leave; + }; + // As in [`classify`]: a plist replaced while it was read is not the plist that was judged. + let stable = probe.identity(&plist).ok().as_ref() == Some(&plist_identity) + && probe.realpath(&plist).ok().as_ref() == Some(®istration) + && probe.identity(®istration).ok().as_ref() == Some(&target_identity) + && probe.realpath(Path::new(app)).ok().as_ref() == Some(&resolved); + if stable && resolved == current_app { + Migration::Rewrite + } else { + Migration::Leave + } +} + +/// Running launchctl and plutil with a deadline, without ever killing a process. +/// +/// The shell never kills a process (`tests/clients/desktop-exit-ownership.test.ts`), so a +/// command that outlives its deadline is abandoned: the caller gets [`RunError::Failed`] at the +/// deadline and a detached thread reaps the child and its output readers later. One process-wide +/// slot is held until all three have finished, and no command starts while it is held, so a +/// launchd that never answers costs at most one abandoned command however often the tray asks. +#[cfg(any(target_os = "macos", all(test, unix)))] +mod command { + use super::{js_trim, RunError}; + use std::io::Read; + use std::process::{Child, Command, Stdio}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::mpsc::{channel, Receiver}; + use std::thread::JoinHandle; + use std::time::{Duration, Instant}; + + const MAX_OUTPUT: u64 = 128 * 1024; + static BUSY: AtomicBool = AtomicBool::new(false); + + /// Occupancy of the one command slot; released when dropped. + struct Slot; + + impl Slot { + fn take() -> Option { + BUSY.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .ok() + .map(|_| Self) + } + } + + impl Drop for Slot { + fn drop(&mut self) { + BUSY.store(false, Ordering::Release); + } + } + + type Reader = (Receiver>, JoinHandle<()>); + + fn drain(stream: Option) -> Reader { + let (sender, receiver) = channel(); + let handle = std::thread::spawn(move || { + let mut bytes = Vec::new(); + if let Some(stream) = stream { + let _ = stream.take(MAX_OUTPUT + 1).read_to_end(&mut bytes); + } + let _ = sender.send(bytes); + }); + (receiver, handle) + } + + /// Hand the slot to a thread that waits for the child and both readers, then frees it. + fn abandon(slot: Slot, mut child: Child, readers: [Reader; 2]) -> RunError { + std::thread::spawn(move || { + let _ = child.wait(); + for (_, handle) in readers { + let _ = handle.join(); + } + drop(slot); + }); + RunError::Failed + } + + pub(super) fn run(program: &str, args: &[&str], timeout: Duration) -> Result { + let slot = Slot::take().ok_or(RunError::Failed)?; + let deadline = Instant::now() + timeout; + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| RunError::Failed)?; + let readers = [drain(child.stdout.take()), drain(child.stderr.take())]; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(5)) + } + _ => return Err(abandon(slot, child, readers)), + } + }; + let mut output = Vec::new(); + for (receiver, _) in &readers { + let left = deadline.saturating_duration_since(Instant::now()); + match receiver.recv_timeout(left) { + Ok(bytes) => output.push(bytes), + Err(_) => return Err(abandon(slot, child, readers)), + } + } + for (_, handle) in readers { + let _ = handle.join(); + } + drop(slot); + let [stdout, stderr] = [&output[0], &output[1]]; + if stdout.len() as u64 > MAX_OUTPUT || stderr.len() as u64 > MAX_OUTPUT { + return Err(RunError::Failed); + } + // Node decodes `encoding: "utf8"` output lossily; so does this. + if status.success() { + return Ok(js_trim(&String::from_utf8_lossy(stdout)).to_string()); + } + match status.code() { + Some(status) => Err(RunError::Exit { + status, + stderr: String::from_utf8_lossy(stderr).into_owned(), + }), + None => Err(RunError::Failed), + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn a_command_past_its_deadline_holds_the_only_slot_until_it_ends() { + let started = Instant::now(); + assert_eq!( + run("/bin/sleep", &["1"], Duration::from_millis(50)), + Err(RunError::Failed) + ); + assert!(started.elapsed() < Duration::from_millis(500)); + // Repeated timeouts cannot add work: nothing runs while the abandoned command lives. + for _ in 0..20 { + assert_eq!( + run("/bin/echo", &["ready"], Duration::from_secs(5)), + Err(RunError::Failed) + ); + } + let freed = Instant::now() + Duration::from_secs(10); + while BUSY.load(Ordering::Acquire) { + assert!( + Instant::now() < freed, + "the abandoned command was never reaped" + ); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + run("/bin/echo", &["ready"], Duration::from_secs(5)), + Ok("ready".into()) + ); + assert_eq!( + run( + "/bin/sh", + &["-c", "echo nope >&2; exit 113"], + Duration::from_secs(5) + ), + Err(RunError::Exit { + status: 113, + stderr: "nope\n".into() + }) + ); + } + } +} + +#[cfg(target_os = "macos")] +pub use system::{judge, judge_migration}; + +#[cfg(target_os = "macos")] +mod system { + use super::*; + use std::ffi::CString; + use std::os::raw::c_char; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::MetadataExt; + use std::time::Duration; + + /// TypeScript's `run` deadline in `desktop-startup.ts`. + const TIMEOUT: Duration = Duration::from_millis(750); + const X_OK: i32 = 1; + + unsafe extern "C" { + fn getuid() -> u32; + fn access(path: *const c_char, mode: i32) -> i32; + } + + pub struct SystemProbe; + + impl Probe for SystemProbe { + fn identity(&self, path: &Path) -> io::Result { + let meta = std::fs::symlink_metadata(path)?; + Ok(format!( + "{}:{}:{}:{}:{}.{}:{}.{}", + meta.dev(), + meta.ino(), + meta.size(), + meta.mode(), + meta.mtime(), + meta.mtime_nsec(), + meta.ctime(), + meta.ctime_nsec() + )) + } + + fn realpath(&self, path: &Path) -> io::Result { + std::fs::canonicalize(path) + } + + fn executable(&self, path: &Path) -> io::Result<()> { + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + // SAFETY: `path` is a NUL-terminated string that outlives the call. + if unsafe { access(path.as_ptr(), X_OK) } == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } + + fn run(&self, program: &str, args: &[&str]) -> Result { + command::run(program, args, TIMEOUT) + } + } + + fn current_app() -> Option { + std::fs::canonicalize(std::env::current_exe().ok()?).ok() + } + + /// Decide the launch-origin migration against the live LaunchAgent. Blocking. + pub fn judge_migration(home: &Path) -> Migration { + match current_app() { + Some(app) => migration(&SystemProbe, home, &app), + None => Migration::Leave, + } + } + + /// Read the live registration. Blocking: run off the main thread. + pub fn judge(home: &Path) -> LoginState { + let Some(app) = current_app() else { + return LoginState::Unknown; + }; + // SAFETY: getuid has no preconditions and cannot fail. + let uid = unsafe { getuid() }; + classify(&SystemProbe, home, uid, &app) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::collections::{BTreeMap, BTreeSet}; + + const CASES: &str = + include_str!("../../../tests/fixtures/desktop-login-registration-cases.json"); + const HOME: &str = "/fixture/home"; + const APP: &str = "/fixture/OpenCodex.app/Contents/MacOS/opencodex-desktop"; + const OTHER_APP: &str = "/fixture/Other/OpenCodex.app/Contents/MacOS/opencodex-desktop"; + const MISSING_APP: &str = "/fixture/Gone/OpenCodex.app/Contents/MacOS/opencodex-desktop"; + const PLIST: &str = "/fixture/home/Library/LaunchAgents/OpenCodex.plist"; + const REAL_PLIST: &str = "/fixture/home/Real.plist"; + const OTHER_PLIST: &str = "/fixture/home/Other.plist"; + + fn sibling(app: &str) -> String { + format!("{}/ocx", app.rsplit_once('/').unwrap().0) + } + + struct Fake { + files: BTreeSet, + links: BTreeMap, + plist: Option, + disabled: Option, + disabled_after: Option, + loaded: Result, + touch: bool, + disabled_reads: Cell, + plutil_calls: Cell, + } + + impl Fake { + fn new(plist: Option) -> Self { + let mut files: BTreeSet = [APP, OTHER_APP, OTHER_PLIST] + .into_iter() + .map(String::from) + .collect(); + files.insert(sibling(APP)); + files.insert(sibling(OTHER_APP)); + if plist.is_some() { + files.insert(PLIST.into()); + } + Self { + files, + links: BTreeMap::new(), + plist, + disabled: Some("disabled services = {\n}".into()), + disabled_after: None, + loaded: Err(RunError::Failed), + touch: false, + disabled_reads: Cell::new(0), + plutil_calls: Cell::new(0), + } + } + + fn known(&self, path: &Path) -> Option { + let path = path.to_str()?.to_string(); + (self.files.contains(&path) || self.links.contains_key(&path)).then_some(path) + } + } + + fn not_found() -> io::Error { + io::Error::from(io::ErrorKind::NotFound) + } + + impl Probe for Fake { + fn identity(&self, path: &Path) -> io::Result { + let path = self.known(path).ok_or_else(not_found)?; + let generation = if self.touch && path == PLIST { + self.plutil_calls.get() + } else { + 0 + }; + Ok(format!("{path}#{generation}")) + } + + fn realpath(&self, path: &Path) -> io::Result { + let path = self.known(path).ok_or_else(not_found)?; + Ok(PathBuf::from( + self.links.get(&path).cloned().unwrap_or(path), + )) + } + + fn executable(&self, path: &Path) -> io::Result<()> { + self.known(path).map(|_| ()).ok_or_else(not_found) + } + + fn run(&self, program: &str, args: &[&str]) -> Result { + match (program, args.first().copied()) { + ("/usr/bin/plutil", _) => { + self.plutil_calls.set(self.plutil_calls.get() + 1); + self.plist + .as_ref() + .map(Value::to_string) + .ok_or(RunError::Failed) + } + ("/bin/launchctl", Some("print-disabled")) => { + let reads = self.disabled_reads.get() + 1; + self.disabled_reads.set(reads); + let after = self.disabled_after.as_ref().filter(|_| reads > 1); + after + .or(self.disabled.as_ref()) + .cloned() + .ok_or(RunError::Failed) + } + ("/bin/launchctl", Some("print")) => self.loaded.clone(), + other => panic!("unexpected command {other:?}"), + } + } + } + + fn fill(text: &str) -> String { + text.replace("{APP}", APP) + .replace("{OTHER_APP}", OTHER_APP) + .replace("{MISSING_APP}", MISSING_APP) + .replace("{PLIST}", PLIST) + .replace("{OTHER_PLIST}", OTHER_PLIST) + .replace("{UID}", "501") + } + + fn fake_for(case: &Value) -> Fake { + let case: Value = serde_json::from_str(&fill(&case.to_string())).unwrap(); + let plist = case.get("plist").filter(|plist| !plist.is_null()).cloned(); + let present = plist.is_some(); + let mut fake = Fake::new(plist); + if case.get("plutilFails") == Some(&Value::Bool(true)) { + fake.plist = None; + } + if case.get("proxy") == Some(&Value::Bool(false)) { + fake.files.remove(&sibling(APP)); + } + if present && case.get("plistLink") == Some(&Value::Bool(true)) { + fake.files.remove(PLIST); + fake.files.insert(REAL_PLIST.into()); + fake.links.insert(PLIST.into(), REAL_PLIST.into()); + } + fake.touch = case.get("touchPlistOnRead") == Some(&Value::Bool(true)); + fake.disabled = case["disabled"].as_str().map(String::from); + fake.disabled_after = case + .get("disabledAfterFirstRead") + .and_then(Value::as_str) + .map(String::from); + let loaded = &case["loaded"]; + fake.loaded = if let Some(stdout) = loaded.get("stdout").and_then(Value::as_str) { + Ok(stdout.into()) + } else if let (Some(status), None) = (loaded.get("status"), loaded.get("signal")) { + Err(RunError::Exit { + status: status.as_i64().unwrap() as i32, + stderr: loaded["stderr"].as_str().unwrap().into(), + }) + } else { + Err(RunError::Failed) + }; + fake + } + + fn name(state: LoginState) -> &'static str { + match state { + LoginState::On => "on", + LoginState::Off => "off", + LoginState::DisabledBySystem => "disabled-by-system", + LoginState::OtherCopy => "other-copy", + LoginState::MissingCopy => "missing-copy", + LoginState::Unknown => "unknown", + } + } + + #[test] + fn shared_fixture_matches_startup_safety() { + let fixture: Value = serde_json::from_str(CASES).unwrap(); + let cases = fixture["cases"].as_array().unwrap(); + assert!(cases.len() > 40); + for case in cases { + let label = case["name"].as_str().unwrap(); + let fake = fake_for(case); + let state = classify(&fake, Path::new(HOME), 501, Path::new(APP)); + assert_eq!(name(state), case["expect"]["tray"], "{label}"); + assert_eq!( + Some(state.checked()), + case["expect"]["loginEnabled"].as_bool(), + "{label}" + ); + } + } + + #[test] + fn only_a_verified_registration_is_checked_or_rewritable() { + let table = [ + (LoginState::On, true, ClickAction::Disable, "Start at Login"), + ( + LoginState::Off, + false, + ClickAction::Enable, + "Start at Login", + ), + ( + LoginState::DisabledBySystem, + false, + ClickAction::OpenLoginItems, + "Start at Login (disabled by macOS)", + ), + ( + LoginState::OtherCopy, + false, + ClickAction::Nothing, + "Start at Login (another OpenCodex copy)", + ), + ( + LoginState::MissingCopy, + false, + ClickAction::Nothing, + "Start at Login (registered app is missing)", + ), + ( + LoginState::Unknown, + false, + ClickAction::Nothing, + "Start at Login (status unknown)", + ), + ]; + for (state, checked, action, label) in table { + assert_eq!(state.checked(), checked, "{state:?}"); + assert_eq!(state.click_action(), action, "{state:?}"); + assert_eq!( + state.clickable(), + action != ClickAction::Nothing, + "{state:?}" + ); + assert_eq!(state.label(), label, "{state:?}"); + } + } + + #[test] + fn migration_rewrites_only_this_copys_own_enabled_entry() { + let entry = |extra: Value| { + let mut plist = serde_json::json!({ + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [APP], + }); + for (key, value) in extra.as_object().unwrap() { + plist[key] = value.clone(); + } + Some(plist) + }; + let cases = [ + ("absent", None, Migration::Absent), + ( + "legacy entry", + entry(serde_json::json!({})), + Migration::Rewrite, + ), + ( + "current arguments", + entry(serde_json::json!({ "ProgramArguments": [APP, "--autostart"] })), + Migration::Rewrite, + ), + ( + "Program equal", + entry(serde_json::json!({ "Program": APP })), + Migration::Leave, + ), + ( + "Disabled false", + entry(serde_json::json!({ "Disabled": false })), + Migration::Leave, + ), + ( + "a key the rewrite would drop", + entry(serde_json::json!({ "KeepAlive": true })), + Migration::Leave, + ), + ( + "another copy", + entry(serde_json::json!({ "ProgramArguments": [OTHER_APP] })), + Migration::Leave, + ), + ( + "missing copy", + entry(serde_json::json!({ "ProgramArguments": [MISSING_APP, "--autostart"] })), + Migration::Leave, + ), + ( + "Program names another copy", + entry(serde_json::json!({ "Program": OTHER_APP })), + Migration::Leave, + ), + ( + "Program names a missing copy", + entry(serde_json::json!({ "Program": MISSING_APP })), + Migration::Leave, + ), + ( + "disabled by its own definition", + entry(serde_json::json!({ "Disabled": true })), + Migration::Leave, + ), + ( + "Disabled is not a boolean", + entry(serde_json::json!({ "Disabled": "yes" })), + Migration::Leave, + ), + ( + "unknown argument", + entry(serde_json::json!({ "ProgramArguments": [APP, "--hidden"] })), + Migration::Leave, + ), + ( + "extra argument", + entry(serde_json::json!({ "ProgramArguments": [APP, "--autostart", "x"] })), + Migration::Leave, + ), + ( + "another label", + entry(serde_json::json!({ "Label": "com.example.OpenCodex" })), + Migration::Leave, + ), + ( + "RunAtLoad false", + entry(serde_json::json!({ "RunAtLoad": false })), + Migration::Leave, + ), + ]; + for (label, plist, expected) in cases { + let fake = Fake::new(plist); + assert_eq!( + migration(&fake, Path::new(HOME), Path::new(APP)), + expected, + "{label}" + ); + } + let mut unreadable = Fake::new(entry(serde_json::json!({}))); + unreadable.plist = None; + assert_eq!( + migration(&unreadable, Path::new(HOME), Path::new(APP)), + Migration::Leave + ); + // Another copy replacing the plist while it is read leaves it alone. + let mut replaced = Fake::new(entry(serde_json::json!({}))); + replaced.touch = true; + assert_eq!( + migration(&replaced, Path::new(HOME), Path::new(APP)), + Migration::Leave + ); + } + + #[test] + fn javascript_whitespace_and_line_terminators() { + assert_eq!(js_trim("\u{feff}\u{a0} x \u{3000}"), "x"); + assert_eq!(js_trim("\u{85}x"), "\u{85}x"); + assert_eq!( + line_value("a\u{2028}program = /x\u{2029}y", "program = "), + Some("/x") + ); + assert_eq!( + line_value("program = \rprogram = /y", "program = "), + Some("/y") + ); + } +} diff --git a/desktop/src-tauri/src/provider_icons.rs b/desktop/src-tauri/src/provider_icons.rs index 045d9e8f16f..2799453c795 100644 --- a/desktop/src-tauri/src/provider_icons.rs +++ b/desktop/src-tauri/src/provider_icons.rs @@ -24,6 +24,7 @@ macro_rules! svg { const ALIASES: &[(&str, &str)] = &[ ("anthropic", "claude-color.svg"), + ("anthropic2", "claude-green.svg"), ("anthropic-apikey", "claude-color.svg"), ("claude-cli", "claude-color.svg"), ("azure-openai", "openai.svg"), @@ -152,6 +153,7 @@ fn svg(file: &str) -> Option<&'static str> { "bizrouter.svg" => svg!("bizrouter.svg"), "cerebras.svg" => svg!("cerebras.svg"), "claude-color.svg" => svg!("claude-color.svg"), + "claude-green.svg" => svg!("claude-green.svg"), "cline-color.svg" => svg!("cline-color.svg"), "cloudflare-ai-gateway-color.svg" => svg!("cloudflare-ai-gateway-color.svg"), "commandcode-color.svg" => svg!("commandcode-color.svg"), diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index 0a8e5572d7e..8ceb94fbd48 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -1471,6 +1471,7 @@ async fn register(app: &AppHandle, deadline: Instant) -> Registration { // than the state from before first run. let login = first_run::apply_start_at_login_default(app); first_run::adopt_launch_origin_argument(app); + crate::cli_command::reconcile_on_launch(app); // The verdict is published only once an icon actually exists. Announcing a tray and then // failing to install it would hide the window into nothing, which is the exact stranding D6 @@ -1616,7 +1617,9 @@ fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) { let mode = startup .as_ref() .map_or(Mode::Launch, |startup| startup.mode()); - if keeps_update_page(mode, crate::window::shows_update_page(&window)) { + if crate::window::shows_cli_page(&window) + || keeps_update_page(mode, crate::window::shows_update_page(&window)) + { return; } if loads_dashboard_on_ready(LaunchOrigin::detect(), visible, requested) { @@ -1655,8 +1658,9 @@ pub fn open_dashboard(app: &AppHandle) { } pub fn return_to_dashboard(app: &AppHandle) -> Result<(), String> { - let startup = app.try_state::().ok_or("dashboard is not ready")?; - let dashboard = startup.ready_dashboard(); + let dashboard = app + .try_state::() + .and_then(|startup| startup.ready_dashboard()); let window = app .get_webview_window("main") .ok_or("dashboard window is unavailable")?; @@ -1669,7 +1673,11 @@ fn return_ready_dashboard( dashboard: Option<&str>, navigate: impl FnOnce(&str) -> bool, ) -> Result<(), String> { - let dashboard = dashboard.ok_or("dashboard is not ready")?; + let dashboard = dashboard.unwrap_or(if cfg!(target_os = "windows") { + "http://tauri.localhost/index.html" + } else { + "tauri://localhost/index.html" + }); if !navigate(dashboard) { return Err("dashboard could not be opened".into()); } @@ -2377,11 +2385,7 @@ mod tests { } #[test] - fn update_page_return_requires_a_ready_dashboard_and_retries_refused_navigation() { - assert_eq!( - return_ready_dashboard(None, |_| true).unwrap_err(), - "dashboard is not ready" - ); + fn settings_page_return_opens_a_ready_dashboard_and_retries_refused_navigation() { assert_eq!( return_ready_dashboard(Some("http://127.0.0.1:10100/#/usage"), |_| false).unwrap_err(), "dashboard could not be opened" @@ -2397,6 +2401,26 @@ mod tests { assert_eq!(visited.as_deref(), Some("http://127.0.0.1:10100/#/usage")); } + #[test] + fn settings_page_return_without_a_ready_dashboard_opens_the_bundled_startup_page() { + assert_eq!( + return_ready_dashboard(None, |_| false).unwrap_err(), + "dashboard could not be opened" + ); + let mut visited = None; + assert!(return_ready_dashboard(None, |url| { + visited = Some(url.to_owned()); + true + }) + .is_ok()); + let expected = if cfg!(target_os = "windows") { + "http://tauri.localhost/index.html" + } else { + "tauri://localhost/index.html" + }; + assert_eq!(visited.as_deref(), Some(expected)); + } + #[test] fn a_login_launch_hides_only_where_there_is_a_tray_to_hide_in() { assert!(!shows_window( diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index eb06e2dd1dc..c702bc7c240 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -14,6 +14,7 @@ use tauri::{ tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, AppHandle, Manager, Wry, }; +#[cfg(not(target_os = "macos"))] use tauri_plugin_autostart::ManagerExt; use tauri_plugin_opener::OpenerExt; @@ -28,6 +29,8 @@ pub struct TrayMenu { check_updates: MenuItem, install_update: MenuItem, stop: MenuItem, + #[cfg(target_os = "macos")] + pub(crate) login: CheckMenuItem, } impl Default for TrayState { @@ -72,6 +75,17 @@ fn update_pending(app: &AppHandle) -> bool { pub fn install(app: &AppHandle) -> tauri::Result<()> { let open = MenuItem::with_id(app, "open-dashboard", "Open Dashboard", true, None::<&str>)?; let browser = MenuItem::with_id(app, "open-browser", "Open in Browser", true, None::<&str>)?; + let cli_command = MenuItem::with_id( + app, + "terminal-command", + "Terminal command…", + true, + None::<&str>, + )?; + // macOS shows only what Startup safety would verify, and probes it off the main thread. + #[cfg(target_os = "macos")] + let login = crate::tray_login::item(app)?; + #[cfg(not(target_os = "macos"))] let login = CheckMenuItem::with_id( app, "start-at-login", @@ -110,6 +124,7 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { &show_usage, &open, &browser, + &cli_command, &PredefinedMenuItem::separator(app)?, &login, &stop, @@ -125,8 +140,12 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { check_updates: check_updates.clone(), install_update: install_update.clone(), stop: stop.clone(), + #[cfg(target_os = "macos")] + login: login.clone(), }); } + #[cfg(target_os = "macos")] + crate::tray_login::refresh(app); let builder = TrayIconBuilder::with_id("main") .icon(icon()) @@ -143,6 +162,11 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { let builder = builder.show_menu_on_left_click(false); let tray = builder .on_tray_icon_event(|tray, event| { + #[cfg(target_os = "macos")] + if let TrayIconEvent::Enter { .. } = event { + crate::tray_login::hover(tray.app_handle()); + return; + } if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, @@ -188,6 +212,7 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { let anchor = tray_anchor(app); let _ = popup::show(app, endpoint, anchor); } + "terminal-command" => crate::cli_command::show_page(app), "open-dashboard" => { crate::startup::open_dashboard(app); } @@ -204,11 +229,16 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { .open_url(format!("{}#/usage", endpoint.url("/")), None::); } "start-at-login" => { - let enabled = app.autolaunch().is_enabled().unwrap_or(false); - if enabled { - let _ = app.autolaunch().disable(); - } else { - let _ = crate::login_autostart::enable(app); + #[cfg(target_os = "macos")] + crate::tray_login::click(app); + #[cfg(not(target_os = "macos"))] + { + let enabled = app.autolaunch().is_enabled().unwrap_or(false); + if enabled { + let _ = app.autolaunch().disable(); + } else { + let _ = crate::login_autostart::enable(app); + } } } "stop-proxy" => { @@ -245,6 +275,8 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { let mut tick = 0; loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; + #[cfg(target_os = "macos")] + crate::tray_login::refresh(&app); let Some(proxy) = app .try_state::() .and_then(|state| state.proxy()) @@ -278,7 +310,7 @@ fn refresh(app: &AppHandle, tray: &tauri::tray::TrayIcon) { /// the main thread and takes this same mutex while doing so, so calling a setter with the lock held /// is a cycle: a background update owns the mutex and waits for the main thread, and the main /// thread waits for the mutex. The app would stop answering Quit. -fn menu_handles(app: &AppHandle) -> Option { +pub(crate) fn menu_handles(app: &AppHandle) -> Option { let state = app.try_state::()?; let handles = state.menu.lock().ok()?; handles.as_ref().cloned() diff --git a/desktop/src-tauri/src/tray_login.rs b/desktop/src-tauri/src/tray_login.rs new file mode 100644 index 00000000000..5126c1f44e7 --- /dev/null +++ b/desktop/src-tauri/src/tray_login.rs @@ -0,0 +1,163 @@ +//! The macOS tray's Start at Login item (#6972). +//! +//! The item shows what [`login_registration::judge`] verifies, the judgment Startup safety uses, +//! and is checked only for [`LoginState::On`]. Reading launchd runs subprocesses, so every probe +//! runs on a blocking worker; one mutex serializes probes with the mutation a click performs. +//! +//! Presentation is applied in one closure on the main thread, the thread that also delivers +//! clicks, so a click and a refresh can never interleave their setters. muda flips a check item +//! before the click reaches the app; the click handler puts back what was shown and holds the +//! item disabled until its own job answers, and refresh results that land meanwhile are dropped. + +use crate::login_registration::{self, ClickAction, LoginState}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, PoisonError, +}; +use std::time::{Duration, Instant}; +use tauri::{menu::CheckMenuItem, AppHandle, Manager, Wry}; +use tauri_plugin_autostart::ManagerExt; +use tauri_plugin_opener::OpenerExt; + +const HOVER_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Default)] +pub struct LoginTray { + /// The state the item shows; `None` until the first probe answers. + shown: Mutex>, + /// Clicks whose job has not applied its answer yet. + pending: AtomicUsize, + /// Serializes probes and the mutation a click performs. + probe: Mutex<()>, + /// When hovering last asked for a refresh. + hovered: Mutex>, +} + +/// The item as built, before anything has been verified: unchecked and inert. +pub fn item(app: &AppHandle) -> tauri::Result> { + app.manage(LoginTray::default()); + let state = LoginState::Unknown; + CheckMenuItem::with_id( + app, + "start-at-login", + state.label(), + state.clickable(), + state.checked(), + None::<&str>, + ) +} + +fn judge(app: &AppHandle) -> LoginState { + match app.path().home_dir() { + Ok(home) => login_registration::judge(&home), + Err(_) => LoginState::Unknown, + } +} + +/// Show `state`. A refresh (`click == false`) yields to any click still waiting for its answer. +fn present(app: &AppHandle, state: LoginState, click: bool) { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + let Some(login) = handle.try_state::() else { + return; + }; + if click { + login.pending.fetch_sub(1, Ordering::AcqRel); + } else if login.pending.load(Ordering::Acquire) > 0 { + return; + } + *login.shown.lock().unwrap_or_else(PoisonError::into_inner) = Some(state); + let busy = login.pending.load(Ordering::Acquire) > 0; + if let Some(menu) = crate::tray::menu_handles(&handle) { + let _ = menu.login.set_text(state.label()); + let _ = menu.login.set_checked(state.checked()); + let _ = menu.login.set_enabled(state.clickable() && !busy); + } + }); +} + +/// Probe again unless a probe or a click is already running. +pub fn refresh(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let Some(login) = app.try_state::() else { + return; + }; + let Ok(_probe) = login.probe.try_lock() else { + return; + }; + let state = judge(&app); + present(&app, state, false); + }); +} + +/// The pointer entered the status item: the menu may be about to open. +pub fn hover(app: &AppHandle) { + let Some(login) = app.try_state::() else { + return; + }; + { + let mut hovered = login.hovered.lock().unwrap_or_else(PoisonError::into_inner); + let now = Instant::now(); + if hovered.is_some_and(|last| now.duration_since(last) < HOVER_INTERVAL) { + return; + } + *hovered = Some(now); + } + refresh(app); +} + +/// Handle a click on the item. Runs on the main thread and never waits for a probe. +pub fn click(app: &AppHandle) { + let Some(login) = app.try_state::() else { + return; + }; + let shown = login + .shown + .lock() + .unwrap_or_else(PoisonError::into_inner) + .unwrap_or(LoginState::Unknown); + login.pending.fetch_add(1, Ordering::AcqRel); + if let Some(menu) = crate::tray::menu_handles(app) { + let _ = menu.login.set_checked(shown.checked()); + let _ = menu.login.set_enabled(false); + } + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let state = match app.try_state::() { + Some(login) => { + let _probe = login.probe.lock().unwrap_or_else(PoisonError::into_inner); + let fresh = judge(&app); + // The click answered what was shown. If the registration has changed since, the + // person has not seen the new state yet: show it and do nothing else. + if fresh == shown && fresh.click_action() != ClickAction::Nothing { + perform(&app, fresh.click_action()); + judge(&app) + } else { + fresh + } + } + None => LoginState::Unknown, + }; + present(&app, state, true); + }); +} + +fn perform(app: &AppHandle, action: ClickAction) { + let result = match action { + ClickAction::Disable => app + .autolaunch() + .disable() + .map_err(|error| error.to_string()), + ClickAction::Enable => crate::login_autostart::enable(app), + // launchd keeps its override whatever the plist says; the person turns it back on. + ClickAction::OpenLoginItems => app + .opener() + .open_url(login_registration::LOGIN_ITEMS_SETTINGS, None::) + .map_err(|error| error.to_string()), + ClickAction::Nothing => Ok(()), + }; + if let Err(error) = result { + crate::logging::log_once("start at login", &error); + } +} diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index b5a90c8cbf5..5c69ae17fe7 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -125,6 +125,43 @@ fn is_update_page_url(url: &Url) -> bool { is_app_origin(url) && url.path() == "/update.html" } +fn is_cli_page_url(url: &Url) -> bool { + is_local_settings_page("main", url) && url.path() == "/cli.html" +} + +fn is_local_settings_page(label: &str, url: &Url) -> bool { + label == "main" + && is_app_origin(url) + && matches!(url.path(), "/update.html" | "/cli.html") + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() +} + +pub fn require_cli_page(window: &WebviewWindow) -> Result<(), String> { + if window.label() != "main" { + return Err("CLI page unavailable".into()); + } + let url = window.url().map_err(|_| "CLI page unavailable")?; + if !is_cli_page_url(&url) { + return Err("CLI page unavailable".into()); + } + Ok(()) +} + +pub fn require_local_settings_page(window: &WebviewWindow) -> Result<(), String> { + let url = window.url().map_err(|_| "settings page unavailable")?; + if !is_local_settings_page(window.label(), &url) { + return Err("settings page unavailable".into()); + } + Ok(()) +} + +pub fn shows_cli_page(window: &WebviewWindow) -> bool { + window.label() == "main" && window.url().is_ok_and(|url| is_cli_page_url(&url)) +} + /// Whether the window currently shows the bundled update page. An unreadable URL reads as not. pub fn shows_update_page(window: &WebviewWindow) -> bool { window.url().is_ok_and(|url| is_update_page_url(&url)) @@ -184,13 +221,45 @@ pub fn set_tray_policy(app: &AppHandle, visible: bool) { #[cfg(test)] mod tests { - use super::{is_app_origin, is_update_page_url, opens_in_default_browser, webview_user_agent}; + use super::{ + is_app_origin, is_cli_page_url, is_local_settings_page, is_update_page_url, + opens_in_default_browser, webview_user_agent, + }; use tauri::Url; fn url(value: &str) -> Url { Url::parse(value).expect("a url") } + #[test] + fn cli_and_local_settings_require_main_and_exact_local_urls() { + for origin in ["tauri://localhost", "http://tauri.localhost"] { + for path in ["/update.html", "/cli.html"] { + let value = url(&format!("{origin}{path}")); + assert!(is_local_settings_page("main", &value)); + assert!(!is_local_settings_page("popup", &value)); + assert_eq!(is_cli_page_url(&value), path == "/cli.html"); + } + } + for value in [ + "http://127.0.0.1:10100/cli.html", + "https://tauri.localhost/cli.html", + "tauri://evil/cli.html", + "http://tauri.localhost:1420/cli.html", + "tauri://localhost:1420/cli.html", + "tauri://localhost/cli.html.evil", + "tauri://localhost/index.html", + "tauri://localhost/cli.html?path=x", + "tauri://localhost/cli.html#x", + "tauri://user@localhost/cli.html", + "http://tauri.localhost:1420/update.html", + "https://evil.example/update.html", + ] { + assert!(!is_local_settings_page("main", &url(value)), "{value}"); + assert!(!is_cli_page_url(&url(value)), "{value}"); + } + } + #[test] fn only_web_addresses_are_handed_to_the_default_browser() { for value in [ diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 352f7802a5b..186419427d8 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.78.0-preview.20261005", + "version": "2.85.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/desktop/ui/cli.html b/desktop/ui/cli.html new file mode 100644 index 00000000000..524c80fa028 --- /dev/null +++ b/desktop/ui/cli.html @@ -0,0 +1,38 @@ + + + + + + OpenCodex terminal command + + + + +
+

Terminal command

+ +

Reading command configuration…

+

+
    + +

    After enabling or repairing, open a new terminal. Existing shells and aliases can still select another command. Use type -a ocx on POSIX, or Get-Command ocx -All and where.exe ocx on Windows to check.

    +

    Remove the terminal command here before uninstalling Desktop. Removing it keeps your off choice for the next launch.

    +
    + + + +
    +
    + + + diff --git a/desktop/ui/cli.js b/desktop/ui/cli.js new file mode 100644 index 00000000000..fb8194b840b --- /dev/null +++ b/desktop/ui/cli.js @@ -0,0 +1,84 @@ +"use strict"; +const invoke = window.__TAURI__?.core?.invoke; +const nodes = Object.fromEntries(["enabled", "state", "target", "issues", "error", "repair", "remove", "back", "titlebar"] + .map(id => [id, document.getElementById(id)])); +let busy = false; +let latest = null; +let polling = false; +let generation = 0; +let refreshQueued = false; +function controls() { + nodes.enabled.disabled = busy || !invoke; + nodes.repair.disabled = busy || !invoke || !latest?.enabled; + nodes.remove.disabled = busy || !invoke; + nodes.back.disabled = busy || !invoke; +} +function render(s) { + latest = s; + nodes.enabled.checked = Boolean(s.enabled); + nodes.state.textContent = { + unobserved: "Configuration has not been inspected yet.", + configured: "Desktop command configuration is installed. Check selection in a new terminal.", + partial: "Some configuration could not be applied. Check the issues below.", + disabled: "Automatic terminal command configuration is off.", + blocked: "Configuration is blocked. Correct the reported issue and try Repair.", + }[s.phase] || "Configuration state is unavailable."; + nodes.target.textContent = s.expectedExecutable ? "Bundled executable: " + s.expectedExecutable : ""; + nodes.issues.replaceChildren(...(s.issues || []).map(code => { + const li = document.createElement("li"); li.textContent = code; return li; + })); + controls(); +} +function bounded(work) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Desktop did not answer within 30 seconds. Refresh status before retrying.")), 30000); + Promise.resolve(work).then(v => { clearTimeout(timer); resolve(v); }, e => { clearTimeout(timer); reject(e); }); + }); +} +async function refresh() { + if (!invoke || busy || polling) return; + const request = ++generation; + polling = true; + try { + const s = await bounded(invoke("cli_status")); + if (request === generation) render(s); + } catch (e) { + if (request === generation) { nodes.error.textContent = String(e); nodes.error.hidden = false; } + } finally { + polling = false; + if (refreshQueued) { refreshQueued = false; await refresh(); } + } +} +async function action(name, args) { + if (!invoke || busy) return; + const request = ++generation; + busy = true; controls(); nodes.error.hidden = true; + try { + const s = await bounded(args === undefined ? invoke(name) : invoke(name, args)); + if (request === generation && s) render(s); + } catch (e) { + if (request === generation) { nodes.error.textContent = String(e); nodes.error.hidden = false; } + } finally { + busy = false; controls(); + if (polling) refreshQueued = true; + else await refresh(); + } +} +nodes.enabled.addEventListener("change", () => action("cli_set_enabled", { enabled: nodes.enabled.checked })); +nodes.repair.addEventListener("click", () => action("cli_install")); +nodes.remove.addEventListener("click", () => action("cli_remove")); +nodes.back.addEventListener("click", () => action("return_to_dashboard")); +if (invoke && typeof navigator !== "undefined" && /Macintosh/.test(navigator.userAgent)) { + nodes.titlebar.hidden = false; + nodes.titlebar.addEventListener("mousedown", e => { + if (e.button === 0) invoke("plugin:window|start_dragging").catch(() => {}); + }); + nodes.titlebar.addEventListener("dblclick", () => invoke("plugin:window|toggle_maximize").catch(() => {})); +} +if (!invoke) { + nodes.state.textContent = "Open this page from OpenCodex Desktop's Terminal command menu."; + controls(); +} else { + refresh(); + setInterval(refresh, 2000); +} diff --git a/desktop/ui/update.html b/desktop/ui/update.html index 22ca486c13e..a816cf1db22 100644 --- a/desktop/ui/update.html +++ b/desktop/ui/update.html @@ -35,6 +35,7 @@

    OpenCodex update

    Reading update status…

    +
    @@ -46,6 +47,8 @@

    OpenCodex update

    const titlebar = document.querySelector("#titlebar"); const state = document.querySelector("#state"); const error = document.querySelector("#error"); +const manual = document.querySelector("#manual"); +const release = document.querySelector("#release"); const check = document.querySelector("#check"); const install = document.querySelector("#install"); const back = document.querySelector("#back"); @@ -65,9 +68,17 @@

    OpenCodex update

    ); }); } -function showError(cause) { - error.textContent = cause instanceof Error ? cause.message : String(cause); +function showError(cause, installFailed = false) { + const message = cause instanceof Error ? cause.message : String(cause); + const version = installFailed ? latest?.latestVersion : null; + const hint = version + ? " If automatic download is slow or blocked by network, install the package manually from GitHub Releases (v" + version + ")." + : ""; + error.textContent = message + hint; error.hidden = false; + manual.hidden = !version; + release.textContent = version ? "Download v" + version + " from GitHub Releases" : ""; + release.href = version ? "https://github.com/lidge-jun/opencodex/releases/tag/v" + encodeURIComponent(version) : ""; } function render(snapshot) { latest = snapshot; @@ -98,6 +109,7 @@

    OpenCodex update

    check.disabled = true; install.disabled = true; error.hidden = true; + manual.hidden = true; state.textContent = name === "update_check" ? "Checking for updates…" : "Reading update status…"; try { render(await call(name)); } catch (cause) { showError(cause); if (latest) render(latest); } @@ -110,14 +122,16 @@

    OpenCodex update

    check.disabled = true; install.disabled = true; error.hidden = true; + manual.hidden = true; state.textContent = "Installing update…"; try { render(await call("update_install")); } - catch (cause) { showError(cause); try { render(await call("update_status")); } catch (readError) { showError(readError); } } + catch (cause) { showError(cause, true); try { render(await call("update_status")); } catch (readError) { showError(readError, true); } } finally { busy = false; if (latest) render(latest); else check.disabled = false; } }); back.addEventListener("click", async () => { back.disabled = true; error.hidden = true; + manual.hidden = true; try { await call("return_to_dashboard"); } catch (cause) { showError(cause); back.disabled = false; } }); diff --git a/devlog/_fin/260808_metadata_pipeline_dejawcode/020_azure_context_refresh.md b/devlog/_fin/260808_metadata_pipeline_dejawcode/020_azure_context_refresh.md new file mode 100644 index 00000000000..aab0a142b51 --- /dev/null +++ b/devlog/_fin/260808_metadata_pipeline_dejawcode/020_azure_context_refresh.md @@ -0,0 +1,47 @@ +# Azure context metadata refresh — 2026-10-08 + +The public `openai` snapshot had 373,000-token windows for GPT-5.6, its Luna/Sol/Terra +variants, GPT-6 Luna/Sol and GPT-6.1 Sol, while the canonical `openai-apikey` registry +declared 1,050,000. Align those seven snapshot windows with the registry and add the +missing GPT-6 Astra row (1,050,000 context, 128,000 output). + +Public metadata cross-check: `https://openrouter.ai/api/v1/models`, read on 2026-10-08, +reported 1,050,000 context and 128,000 maximum output for the seven named variants +above, including Astra. Astra prices per million tokens were input 10, output 50, +cache read 1 and cache write 12.5. The plain `gpt-5.6` window follows the existing +canonical API registry seed; it was not independently listed by this source. +The `openai-codex` and `openrouter` snapshot bundles retain their separate contracts. + +This is model metadata, not proof of an individual Azure deployment's capacity. The +destination-gated catalog fallback runs only when reported/configured limits are absent. +Regression coverage includes serialized catalog context and compaction, case matching, +unknown aliases, non-Azure destinations, explicit limits and provider caps. + +The same catalog fallback fills missing input modalities and output limits. The public +`https://models.dev/api.json` Azure provider rows were read on 2026-10-08: 96 models +in a 5,362,373-byte catalog. Azure `gpt-6.1-sol` reports text/image/pdf input, +1,050,000 context and 128,000 output; Azure `deepseek-v4-flash` reports text-only +input, 1,000,000 context and 384,000 output. The direct-vendor DeepSeek row differs, +so published Azure fields take precedence over vendor fallback. Codex accepts only +text/image/audio input values; unsupported values such as pdf are filtered. + +Discovery refreshes and caches the public Azure subset for 24 hours, with a two-second +deadline and 16 MiB byte ceiling, without sending provider credentials. Missing or +malformed metadata keeps discovery working; stale data remains usable offline. New +published ids can acquire image metadata without a bundled-data release. Azure inference +capability flags alone do not identify modalities, and arbitrary deployment aliases are +not guessed. Explicit declarations, reported modalities and sidecar behavior remain intact. + +Cache state is isolated by the resolved configuration directory, including snapshots, +in-flight refreshes and failure retry cooldowns. A refresh captures its directory before +awaiting so a root switch cannot redirect its disk write or expose its memory snapshot +to another root. Regression coverage switches A to B and back, including concurrent +refreshes and an offline cooldown. + +Discovery admission carries its metadata directory through refresh, upstream awaits +and subsequent hint projections, including cached rows, retention and combo/custom +rows. The public metadata fetch rejects redirects and retains stale fallback data. + +Azure discovery caches upstream rows before metadata enrichment. Fresh, stale and +failure-cooldown reads project those rows using the admitted configuration root, +so A-to-B-to-A calls can reuse one upstream result while keeping separate metadata. diff --git a/devlog/_plan/260819_next_roadmap/000_roadmap.md b/devlog/_fin/260819_next_roadmap/000_roadmap.md similarity index 100% rename from devlog/_plan/260819_next_roadmap/000_roadmap.md rename to devlog/_fin/260819_next_roadmap/000_roadmap.md diff --git a/devlog/_plan/260819_next_roadmap/010_r1_split_rebase.md b/devlog/_fin/260819_next_roadmap/010_r1_split_rebase.md similarity index 100% rename from devlog/_plan/260819_next_roadmap/010_r1_split_rebase.md rename to devlog/_fin/260819_next_roadmap/010_r1_split_rebase.md diff --git a/devlog/_plan/260819_next_roadmap/020_r2_temp_reclaim_merge.md b/devlog/_fin/260819_next_roadmap/020_r2_temp_reclaim_merge.md similarity index 100% rename from devlog/_plan/260819_next_roadmap/020_r2_temp_reclaim_merge.md rename to devlog/_fin/260819_next_roadmap/020_r2_temp_reclaim_merge.md diff --git a/devlog/_plan/260819_next_roadmap/030_r3_collisions_and_retargets.md b/devlog/_fin/260819_next_roadmap/030_r3_collisions_and_retargets.md similarity index 100% rename from devlog/_plan/260819_next_roadmap/030_r3_collisions_and_retargets.md rename to devlog/_fin/260819_next_roadmap/030_r3_collisions_and_retargets.md diff --git a/devlog/_plan/260819_next_roadmap/031_r3_posted_decisions.md b/devlog/_fin/260819_next_roadmap/031_r3_posted_decisions.md similarity index 100% rename from devlog/_plan/260819_next_roadmap/031_r3_posted_decisions.md rename to devlog/_fin/260819_next_roadmap/031_r3_posted_decisions.md diff --git a/devlog/_plan/260819_next_roadmap/040_r4_modelrecordvalue_batch.md b/devlog/_fin/260819_next_roadmap/040_r4_modelrecordvalue_batch.md similarity index 100% rename from devlog/_plan/260819_next_roadmap/040_r4_modelrecordvalue_batch.md rename to devlog/_fin/260819_next_roadmap/040_r4_modelrecordvalue_batch.md diff --git a/devlog/_plan/260819_next_roadmap/041_r4_posted_verdicts.md b/devlog/_fin/260819_next_roadmap/041_r4_posted_verdicts.md similarity index 100% rename from devlog/_plan/260819_next_roadmap/041_r4_posted_verdicts.md rename to devlog/_fin/260819_next_roadmap/041_r4_posted_verdicts.md diff --git a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md b/devlog/_fin/260819_next_roadmap/050_execution_ledger.md similarity index 100% rename from devlog/_plan/260819_next_roadmap/050_execution_ledger.md rename to devlog/_fin/260819_next_roadmap/050_execution_ledger.md diff --git a/devlog/_plan/260819_next_roadmap/060_outcome.md b/devlog/_fin/260819_next_roadmap/060_outcome.md similarity index 100% rename from devlog/_plan/260819_next_roadmap/060_outcome.md rename to devlog/_fin/260819_next_roadmap/060_outcome.md diff --git a/devlog/_plan/260819_next_roadmap/070_next_roadmap_split_and_dogfood.md b/devlog/_fin/260819_next_roadmap/070_next_roadmap_split_and_dogfood.md similarity index 100% rename from devlog/_plan/260819_next_roadmap/070_next_roadmap_split_and_dogfood.md rename to devlog/_fin/260819_next_roadmap/070_next_roadmap_split_and_dogfood.md diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_fin/260819_next_roadmap/080_merge_loop_ledger.md similarity index 100% rename from devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md rename to devlog/_fin/260819_next_roadmap/080_merge_loop_ledger.md diff --git a/devlog/_plan/260819_next_roadmap/090_merge_loop_outcome.md b/devlog/_fin/260819_next_roadmap/090_merge_loop_outcome.md similarity index 100% rename from devlog/_plan/260819_next_roadmap/090_merge_loop_outcome.md rename to devlog/_fin/260819_next_roadmap/090_merge_loop_outcome.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/000_investigation.md b/devlog/_fin/260819_unclaimed_bug_selection/000_investigation.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/000_investigation.md rename to devlog/_fin/260819_unclaimed_bug_selection/000_investigation.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/010_ranking.md b/devlog/_fin/260819_unclaimed_bug_selection/010_ranking.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/010_ranking.md rename to devlog/_fin/260819_unclaimed_bug_selection/010_ranking.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/020_2114_systemd_bus.md b/devlog/_fin/260819_unclaimed_bug_selection/020_2114_systemd_bus.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/020_2114_systemd_bus.md rename to devlog/_fin/260819_unclaimed_bug_selection/020_2114_systemd_bus.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/030_2107_service_proxy_env.md b/devlog/_fin/260819_unclaimed_bug_selection/030_2107_service_proxy_env.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/030_2107_service_proxy_env.md rename to devlog/_fin/260819_unclaimed_bug_selection/030_2107_service_proxy_env.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/031_2107_implementation.md b/devlog/_fin/260819_unclaimed_bug_selection/031_2107_implementation.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/031_2107_implementation.md rename to devlog/_fin/260819_unclaimed_bug_selection/031_2107_implementation.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/040_2108_windows_reboot_gate.md b/devlog/_fin/260819_unclaimed_bug_selection/040_2108_windows_reboot_gate.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/040_2108_windows_reboot_gate.md rename to devlog/_fin/260819_unclaimed_bug_selection/040_2108_windows_reboot_gate.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/050_1587_deferred_catalog.md b/devlog/_fin/260819_unclaimed_bug_selection/050_1587_deferred_catalog.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/050_1587_deferred_catalog.md rename to devlog/_fin/260819_unclaimed_bug_selection/050_1587_deferred_catalog.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/060_1933_tray_encoding.md b/devlog/_fin/260819_unclaimed_bug_selection/060_1933_tray_encoding.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/060_1933_tray_encoding.md rename to devlog/_fin/260819_unclaimed_bug_selection/060_1933_tray_encoding.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/061_1933_implementation.md b/devlog/_fin/260819_unclaimed_bug_selection/061_1933_implementation.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/061_1933_implementation.md rename to devlog/_fin/260819_unclaimed_bug_selection/061_1933_implementation.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/070_sequencing.md b/devlog/_fin/260819_unclaimed_bug_selection/070_sequencing.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/070_sequencing.md rename to devlog/_fin/260819_unclaimed_bug_selection/070_sequencing.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/075_verification.md b/devlog/_fin/260819_unclaimed_bug_selection/075_verification.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/075_verification.md rename to devlog/_fin/260819_unclaimed_bug_selection/075_verification.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/080_outcome.md b/devlog/_fin/260819_unclaimed_bug_selection/080_outcome.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/080_outcome.md rename to devlog/_fin/260819_unclaimed_bug_selection/080_outcome.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/090_1527_abort_slice.md b/devlog/_fin/260819_unclaimed_bug_selection/090_1527_abort_slice.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/090_1527_abort_slice.md rename to devlog/_fin/260819_unclaimed_bug_selection/090_1527_abort_slice.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/100_post_split_rebase.md b/devlog/_fin/260819_unclaimed_bug_selection/100_post_split_rebase.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/100_post_split_rebase.md rename to devlog/_fin/260819_unclaimed_bug_selection/100_post_split_rebase.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/110_2114_disposition.md b/devlog/_fin/260819_unclaimed_bug_selection/110_2114_disposition.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/110_2114_disposition.md rename to devlog/_fin/260819_unclaimed_bug_selection/110_2114_disposition.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/120_2108_phase1.md b/devlog/_fin/260819_unclaimed_bug_selection/120_2108_phase1.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/120_2108_phase1.md rename to devlog/_fin/260819_unclaimed_bug_selection/120_2108_phase1.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/121_2108_implementation.md b/devlog/_fin/260819_unclaimed_bug_selection/121_2108_implementation.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/121_2108_implementation.md rename to devlog/_fin/260819_unclaimed_bug_selection/121_2108_implementation.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/130_ci_proxy_env_leak.md b/devlog/_fin/260819_unclaimed_bug_selection/130_ci_proxy_env_leak.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/130_ci_proxy_env_leak.md rename to devlog/_fin/260819_unclaimed_bug_selection/130_ci_proxy_env_leak.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/140_final_audit.md b/devlog/_fin/260819_unclaimed_bug_selection/140_final_audit.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/140_final_audit.md rename to devlog/_fin/260819_unclaimed_bug_selection/140_final_audit.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/150_outcome.md b/devlog/_fin/260819_unclaimed_bug_selection/150_outcome.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/150_outcome.md rename to devlog/_fin/260819_unclaimed_bug_selection/150_outcome.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/170_2114_2108_fences.md b/devlog/_fin/260819_unclaimed_bug_selection/170_2114_2108_fences.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/170_2114_2108_fences.md rename to devlog/_fin/260819_unclaimed_bug_selection/170_2114_2108_fences.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/180_windows_leg.md b/devlog/_fin/260819_unclaimed_bug_selection/180_windows_leg.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/180_windows_leg.md rename to devlog/_fin/260819_unclaimed_bug_selection/180_windows_leg.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/190_release_readiness.md b/devlog/_fin/260819_unclaimed_bug_selection/190_release_readiness.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/190_release_readiness.md rename to devlog/_fin/260819_unclaimed_bug_selection/190_release_readiness.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/200_local_windows_verification.md b/devlog/_fin/260819_unclaimed_bug_selection/200_local_windows_verification.md similarity index 100% rename from devlog/_plan/260819_unclaimed_bug_selection/200_local_windows_verification.md rename to devlog/_fin/260819_unclaimed_bug_selection/200_local_windows_verification.md diff --git a/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md b/devlog/_fin/260821_bug_merge_train/000_triage_matrix.md similarity index 100% rename from devlog/_plan/260821_bug_merge_train/000_triage_matrix.md rename to devlog/_fin/260821_bug_merge_train/000_triage_matrix.md diff --git a/devlog/_plan/260821_bug_merge_train/001_dependency_analysis.md b/devlog/_fin/260821_bug_merge_train/001_dependency_analysis.md similarity index 100% rename from devlog/_plan/260821_bug_merge_train/001_dependency_analysis.md rename to devlog/_fin/260821_bug_merge_train/001_dependency_analysis.md diff --git a/devlog/_plan/260821_bug_merge_train/002_audit_synthesis.md b/devlog/_fin/260821_bug_merge_train/002_audit_synthesis.md similarity index 100% rename from devlog/_plan/260821_bug_merge_train/002_audit_synthesis.md rename to devlog/_fin/260821_bug_merge_train/002_audit_synthesis.md diff --git a/devlog/_plan/260821_bug_merge_train/010_fix_dev_macos_ci.md b/devlog/_fin/260821_bug_merge_train/010_fix_dev_macos_ci.md similarity index 100% rename from devlog/_plan/260821_bug_merge_train/010_fix_dev_macos_ci.md rename to devlog/_fin/260821_bug_merge_train/010_fix_dev_macos_ci.md diff --git a/devlog/_plan/260821_bug_merge_train/020_merge_2295.md b/devlog/_fin/260821_bug_merge_train/020_merge_2295.md similarity index 100% rename from devlog/_plan/260821_bug_merge_train/020_merge_2295.md rename to devlog/_fin/260821_bug_merge_train/020_merge_2295.md diff --git a/devlog/_plan/260821_bug_merge_train/030_merge_2294.md b/devlog/_fin/260821_bug_merge_train/030_merge_2294.md similarity index 100% rename from devlog/_plan/260821_bug_merge_train/030_merge_2294.md rename to devlog/_fin/260821_bug_merge_train/030_merge_2294.md diff --git a/devlog/_plan/260821_bug_merge_train/040_merge_2296.md b/devlog/_fin/260821_bug_merge_train/040_merge_2296.md similarity index 100% rename from devlog/_plan/260821_bug_merge_train/040_merge_2296.md rename to devlog/_fin/260821_bug_merge_train/040_merge_2296.md diff --git a/devlog/_plan/260821_bug_merge_train/050_merge_2289.md b/devlog/_fin/260821_bug_merge_train/050_merge_2289.md similarity index 100% rename from devlog/_plan/260821_bug_merge_train/050_merge_2289.md rename to devlog/_fin/260821_bug_merge_train/050_merge_2289.md diff --git a/devlog/_plan/260821_bug_merge_train/060_merge_2270.md b/devlog/_fin/260821_bug_merge_train/060_merge_2270.md similarity index 100% rename from devlog/_plan/260821_bug_merge_train/060_merge_2270.md rename to devlog/_fin/260821_bug_merge_train/060_merge_2270.md diff --git a/devlog/_plan/260821_bug_merge_train/065_merge_2281.md b/devlog/_fin/260821_bug_merge_train/065_merge_2281.md similarity index 100% rename from devlog/_plan/260821_bug_merge_train/065_merge_2281.md rename to devlog/_fin/260821_bug_merge_train/065_merge_2281.md diff --git a/devlog/_plan/260821_bug_merge_train/070_final_gate.md b/devlog/_fin/260821_bug_merge_train/070_final_gate.md similarity index 100% rename from devlog/_plan/260821_bug_merge_train/070_final_gate.md rename to devlog/_fin/260821_bug_merge_train/070_final_gate.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/000_plan.md b/devlog/_fin/260822_senpi_cursor_transfer/000_plan.md similarity index 98% rename from devlog/_plan/260822_senpi_cursor_transfer/000_plan.md rename to devlog/_fin/260822_senpi_cursor_transfer/000_plan.md index 75685484b4d..03227694671 100644 --- a/devlog/_plan/260822_senpi_cursor_transfer/000_plan.md +++ b/devlog/_fin/260822_senpi_cursor_transfer/000_plan.md @@ -45,7 +45,7 @@ Do not implement two slices in one B. Do not start wp1 until this research cycle ## IN / OUT -IN: this `devlog/_plan/260822_senpi_cursor_transfer/` directory. +IN: this `devlog/_fin/260822_senpi_cursor_transfer/` directory. OUT: `src/`, `tests/`, `gui/`, `docs-site/`; senpi vendored proto copy; CLI spawn of `cursor-agent`. ## Already-have headline diff --git a/devlog/_plan/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md b/devlog/_fin/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md rename to devlog/_fin/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md b/devlog/_fin/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md rename to devlog/_fin/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/003_protocol_compare.md b/devlog/_fin/260822_senpi_cursor_transfer/003_protocol_compare.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/003_protocol_compare.md rename to devlog/_fin/260822_senpi_cursor_transfer/003_protocol_compare.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/004_auth_catalog_compare.md b/devlog/_fin/260822_senpi_cursor_transfer/004_auth_catalog_compare.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/004_auth_catalog_compare.md rename to devlog/_fin/260822_senpi_cursor_transfer/004_auth_catalog_compare.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/005_exec_compare.md b/devlog/_fin/260822_senpi_cursor_transfer/005_exec_compare.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/005_exec_compare.md rename to devlog/_fin/260822_senpi_cursor_transfer/005_exec_compare.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/006_stream_overflow_compare.md b/devlog/_fin/260822_senpi_cursor_transfer/006_stream_overflow_compare.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/006_stream_overflow_compare.md rename to devlog/_fin/260822_senpi_cursor_transfer/006_stream_overflow_compare.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/007_cli_fallback.md b/devlog/_fin/260822_senpi_cursor_transfer/007_cli_fallback.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/007_cli_fallback.md rename to devlog/_fin/260822_senpi_cursor_transfer/007_cli_fallback.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/090_transfer_verdict.md b/devlog/_fin/260822_senpi_cursor_transfer/090_transfer_verdict.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/090_transfer_verdict.md rename to devlog/_fin/260822_senpi_cursor_transfer/090_transfer_verdict.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/100_stabilization_round2_plan.md b/devlog/_fin/260822_senpi_cursor_transfer/100_stabilization_round2_plan.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/100_stabilization_round2_plan.md rename to devlog/_fin/260822_senpi_cursor_transfer/100_stabilization_round2_plan.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/110_stream_health_watchdog.md b/devlog/_fin/260822_senpi_cursor_transfer/110_stream_health_watchdog.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/110_stream_health_watchdog.md rename to devlog/_fin/260822_senpi_cursor_transfer/110_stream_health_watchdog.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/120_small_hardening_pair.md b/devlog/_fin/260822_senpi_cursor_transfer/120_small_hardening_pair.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/120_small_hardening_pair.md rename to devlog/_fin/260822_senpi_cursor_transfer/120_small_hardening_pair.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/190_round2_roadmap_lock.md b/devlog/_fin/260822_senpi_cursor_transfer/190_round2_roadmap_lock.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/190_round2_roadmap_lock.md rename to devlog/_fin/260822_senpi_cursor_transfer/190_round2_roadmap_lock.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/200_round3_probe_plan.md b/devlog/_fin/260822_senpi_cursor_transfer/200_round3_probe_plan.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/200_round3_probe_plan.md rename to devlog/_fin/260822_senpi_cursor_transfer/200_round3_probe_plan.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/210_maxmode.md b/devlog/_fin/260822_senpi_cursor_transfer/210_maxmode.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/210_maxmode.md rename to devlog/_fin/260822_senpi_cursor_transfer/210_maxmode.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/220_rotation.md b/devlog/_fin/260822_senpi_cursor_transfer/220_rotation.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/220_rotation.md rename to devlog/_fin/260822_senpi_cursor_transfer/220_rotation.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/230_issue2305.md b/devlog/_fin/260822_senpi_cursor_transfer/230_issue2305.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/230_issue2305.md rename to devlog/_fin/260822_senpi_cursor_transfer/230_issue2305.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/240_client_version.md b/devlog/_fin/260822_senpi_cursor_transfer/240_client_version.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/240_client_version.md rename to devlog/_fin/260822_senpi_cursor_transfer/240_client_version.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/250_billed_usage.md b/devlog/_fin/260822_senpi_cursor_transfer/250_billed_usage.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/250_billed_usage.md rename to devlog/_fin/260822_senpi_cursor_transfer/250_billed_usage.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/260_re_classification_refinement.md b/devlog/_fin/260822_senpi_cursor_transfer/260_re_classification_refinement.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/260_re_classification_refinement.md rename to devlog/_fin/260822_senpi_cursor_transfer/260_re_classification_refinement.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/290_round3_lock.md b/devlog/_fin/260822_senpi_cursor_transfer/290_round3_lock.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/290_round3_lock.md rename to devlog/_fin/260822_senpi_cursor_transfer/290_round3_lock.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/300_opus_fast_catalog.md b/devlog/_fin/260822_senpi_cursor_transfer/300_opus_fast_catalog.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/300_opus_fast_catalog.md rename to devlog/_fin/260822_senpi_cursor_transfer/300_opus_fast_catalog.md diff --git a/devlog/_plan/260822_senpi_cursor_transfer/310_maxmode_bigctx.md b/devlog/_fin/260822_senpi_cursor_transfer/310_maxmode_bigctx.md similarity index 100% rename from devlog/_plan/260822_senpi_cursor_transfer/310_maxmode_bigctx.md rename to devlog/_fin/260822_senpi_cursor_transfer/310_maxmode_bigctx.md diff --git a/devlog/_plan/260824_model_ux_aliases_and_defaults/000_plan.md b/devlog/_fin/260824_model_ux_aliases_and_defaults/000_plan.md similarity index 97% rename from devlog/_plan/260824_model_ux_aliases_and_defaults/000_plan.md rename to devlog/_fin/260824_model_ux_aliases_and_defaults/000_plan.md index b935c2678a9..4c4e6b8202c 100644 --- a/devlog/_plan/260824_model_ux_aliases_and_defaults/000_plan.md +++ b/devlog/_fin/260824_model_ux_aliases_and_defaults/000_plan.md @@ -45,7 +45,7 @@ catalog refresh introduces new models already enabled. ## Loop-spec - Loop archetype: verifier-defined (issues exist + PR merged = done). -- Write scope: `devlog/_plan/260824_model_ux_aliases_and_defaults/**` only. +- Write scope: `devlog/_fin/260824_model_ux_aliases_and_defaults/**` only. Out of scope: any `src/`, `gui/`, `docs-site/` change; implementation. - External writes: 3 issues on lidge-jun/opencodex; 1 PR to `dev` + merge (explicitly user-authorized in the request). diff --git a/devlog/_plan/260824_model_ux_aliases_and_defaults/010_aliases.md b/devlog/_fin/260824_model_ux_aliases_and_defaults/010_aliases.md similarity index 100% rename from devlog/_plan/260824_model_ux_aliases_and_defaults/010_aliases.md rename to devlog/_fin/260824_model_ux_aliases_and_defaults/010_aliases.md diff --git a/devlog/_plan/260824_model_ux_aliases_and_defaults/020_new_models_off.md b/devlog/_fin/260824_model_ux_aliases_and_defaults/020_new_models_off.md similarity index 100% rename from devlog/_plan/260824_model_ux_aliases_and_defaults/020_new_models_off.md rename to devlog/_fin/260824_model_ux_aliases_and_defaults/020_new_models_off.md diff --git a/devlog/_plan/260824_model_ux_aliases_and_defaults/030_default_preset.md b/devlog/_fin/260824_model_ux_aliases_and_defaults/030_default_preset.md similarity index 100% rename from devlog/_plan/260824_model_ux_aliases_and_defaults/030_default_preset.md rename to devlog/_fin/260824_model_ux_aliases_and_defaults/030_default_preset.md diff --git a/devlog/_plan/260825_operator_visibility_train/000_baseline_and_scope.md b/devlog/_fin/260825_operator_visibility_train/000_baseline_and_scope.md similarity index 100% rename from devlog/_plan/260825_operator_visibility_train/000_baseline_and_scope.md rename to devlog/_fin/260825_operator_visibility_train/000_baseline_and_scope.md diff --git a/devlog/_plan/260825_operator_visibility_train/001_current_state_inventory.md b/devlog/_fin/260825_operator_visibility_train/001_current_state_inventory.md similarity index 100% rename from devlog/_plan/260825_operator_visibility_train/001_current_state_inventory.md rename to devlog/_fin/260825_operator_visibility_train/001_current_state_inventory.md diff --git a/devlog/_plan/260825_operator_visibility_train/002_plan_audit.md b/devlog/_fin/260825_operator_visibility_train/002_plan_audit.md similarity index 100% rename from devlog/_plan/260825_operator_visibility_train/002_plan_audit.md rename to devlog/_fin/260825_operator_visibility_train/002_plan_audit.md diff --git a/devlog/_plan/260825_operator_visibility_train/010_wp2_issue2457_sidecar_backend_resolution.md b/devlog/_fin/260825_operator_visibility_train/010_wp2_issue2457_sidecar_backend_resolution.md similarity index 100% rename from devlog/_plan/260825_operator_visibility_train/010_wp2_issue2457_sidecar_backend_resolution.md rename to devlog/_fin/260825_operator_visibility_train/010_wp2_issue2457_sidecar_backend_resolution.md diff --git a/devlog/_plan/260825_operator_visibility_train/020_wp3_issue2411_status_routing_visibility.md b/devlog/_fin/260825_operator_visibility_train/020_wp3_issue2411_status_routing_visibility.md similarity index 100% rename from devlog/_plan/260825_operator_visibility_train/020_wp3_issue2411_status_routing_visibility.md rename to devlog/_fin/260825_operator_visibility_train/020_wp3_issue2411_status_routing_visibility.md diff --git a/devlog/_plan/260825_operator_visibility_train/030_wp4_issue2412_version_manager_shim.md b/devlog/_fin/260825_operator_visibility_train/030_wp4_issue2412_version_manager_shim.md similarity index 100% rename from devlog/_plan/260825_operator_visibility_train/030_wp4_issue2412_version_manager_shim.md rename to devlog/_fin/260825_operator_visibility_train/030_wp4_issue2412_version_manager_shim.md diff --git a/devlog/_plan/260826_config_rebase_provenance/010_design.md b/devlog/_fin/260826_config_rebase_provenance/010_design.md similarity index 100% rename from devlog/_plan/260826_config_rebase_provenance/010_design.md rename to devlog/_fin/260826_config_rebase_provenance/010_design.md diff --git a/devlog/_plan/260826_cursor_responses_gap/000_plan.md b/devlog/_fin/260826_cursor_responses_gap/000_plan.md similarity index 97% rename from devlog/_plan/260826_cursor_responses_gap/000_plan.md rename to devlog/_fin/260826_cursor_responses_gap/000_plan.md index 748f3be5483..4840cd4073e 100644 --- a/devlog/_plan/260826_cursor_responses_gap/000_plan.md +++ b/devlog/_fin/260826_cursor_responses_gap/000_plan.md @@ -15,7 +15,7 @@ live probe campaign against `cursor/grok-4.6` through the local proxy - Goal: severity-ranked gap catalog with file:line citations + >=10 probe evidence rows. - Non-goals: fixing the adapter, provider config changes, issues/PRs, pushes. -- Verifier: `ls devlog/_plan/260826_cursor_responses_gap` (docs), +- Verifier: `ls devlog/_fin/260826_cursor_responses_gap` (docs), `git status --short src/` (must be empty), probe-count grep in 010 doc. All three run and read the change target (this unit's files / src tree). - Stop: all three docs written and consistent; bounds = ~40min probing, @@ -98,7 +98,7 @@ probes toward the >=10 minimum. secondary references. durationMs:0 is plausibly Codex-client-side — 001 carries it as UNKNOWN with the evidence gap stated. 4. (Medium) Probe-count verifier is now concrete: - `rg -c '^\| (P|C|S)[0-9]' devlog/_plan/260826_cursor_responses_gap/010_probe_campaign.md` + `rg -c '^\| (P|C|S)[0-9]' devlog/_fin/260826_cursor_responses_gap/010_probe_campaign.md` must report >= 10 evidence rows (pass condition), and each row carries a pass/fail column. diff --git a/devlog/_plan/260826_cursor_responses_gap/001_seed_thread_failure_catalog.md b/devlog/_fin/260826_cursor_responses_gap/001_seed_thread_failure_catalog.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/001_seed_thread_failure_catalog.md rename to devlog/_fin/260826_cursor_responses_gap/001_seed_thread_failure_catalog.md diff --git a/devlog/_plan/260826_cursor_responses_gap/010_probe_campaign.md b/devlog/_fin/260826_cursor_responses_gap/010_probe_campaign.md similarity index 99% rename from devlog/_plan/260826_cursor_responses_gap/010_probe_campaign.md rename to devlog/_fin/260826_cursor_responses_gap/010_probe_campaign.md index 791e8a9345d..d5382f8f710 100644 --- a/devlog/_plan/260826_cursor_responses_gap/010_probe_campaign.md +++ b/devlog/_fin/260826_cursor_responses_gap/010_probe_campaign.md @@ -8,7 +8,7 @@ cursor-model workers (S1), plugin-surface exposure (S2), all fenced to read-only tasks or mktemp scratch. Verifier (from 000 plan): -`rg -c '^\| (P|C|S)[0-9]' devlog/_plan/260826_cursor_responses_gap/010_probe_campaign.md` +`rg -c '^\| (P|C|S)[0-9]' devlog/_fin/260826_cursor_responses_gap/010_probe_campaign.md` pass condition >= 10 evidence rows. Bounds: <=30 live probes, <=3 retries/class, ~40min wall clock. diff --git a/devlog/_plan/260826_cursor_responses_gap/020_gap_summary.md b/devlog/_fin/260826_cursor_responses_gap/020_gap_summary.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/020_gap_summary.md rename to devlog/_fin/260826_cursor_responses_gap/020_gap_summary.md diff --git a/devlog/_plan/260826_cursor_responses_gap/025_ultra_k3_research.md b/devlog/_fin/260826_cursor_responses_gap/025_ultra_k3_research.md similarity index 96% rename from devlog/_plan/260826_cursor_responses_gap/025_ultra_k3_research.md rename to devlog/_fin/260826_cursor_responses_gap/025_ultra_k3_research.md index a9592a10b1d..60d2ee5763b 100644 --- a/devlog/_plan/260826_cursor_responses_gap/025_ultra_k3_research.md +++ b/devlog/_fin/260826_cursor_responses_gap/025_ultra_k3_research.md @@ -14,7 +14,7 @@ Claim ledger — status per cxc-search discipline: | K3 1M specifically unlocked on Ultra | user-confirmed 2026-08-26 (operator saw the 1M option live in Cursor on the Ultra plan); public primary source still absent | user observation (authoritative for this deployment) + reddit lead | | Wire: max mode = RequestedModel.max_mode (field 2) AND ModelDetails.max_mode (field 7); missing either can invalid_argument | lead (2 impl sources) | oh-my-pi #4969, cursor-opencode-provider | | 1M exposure pattern: synthetic -1m picker variant w/ limit.context=1M, wire sends original id + maxMode | lead | cursor-opencode-provider README | -| In-repo: GetUsableModels ModelDetails.maxMode=true observed on 28 -fast ids (260822); no contextTokenLimit field | verified (own probe) | devlog/_plan/260822_senpi_cursor_transfer/210_maxmode.md | +| In-repo: GetUsableModels ModelDetails.maxMode=true observed on 28 -fast ids (260822); no contextTokenLimit field | verified (own probe) | devlog/_fin/260822_senpi_cursor_transfer/210_maxmode.md | Design consequence for 070 (ultra toggle): treat "ultra" as a per-model toggle that (a) sets the wire maxMode flag in both RequestedModel and diff --git a/devlog/_plan/260826_cursor_responses_gap/030_callid_sse_parity.md b/devlog/_fin/260826_cursor_responses_gap/030_callid_sse_parity.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/030_callid_sse_parity.md rename to devlog/_fin/260826_cursor_responses_gap/030_callid_sse_parity.md diff --git a/devlog/_plan/260826_cursor_responses_gap/040_preamble_floor.md b/devlog/_fin/260826_cursor_responses_gap/040_preamble_floor.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/040_preamble_floor.md rename to devlog/_fin/260826_cursor_responses_gap/040_preamble_floor.md diff --git a/devlog/_plan/260826_cursor_responses_gap/050_checkpoint_continuity.md b/devlog/_fin/260826_cursor_responses_gap/050_checkpoint_continuity.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/050_checkpoint_continuity.md rename to devlog/_fin/260826_cursor_responses_gap/050_checkpoint_continuity.md diff --git a/devlog/_plan/260826_cursor_responses_gap/060_catalog_honesty.md b/devlog/_fin/260826_cursor_responses_gap/060_catalog_honesty.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/060_catalog_honesty.md rename to devlog/_fin/260826_cursor_responses_gap/060_catalog_honesty.md diff --git a/devlog/_plan/260826_cursor_responses_gap/070_ultra_mode.md b/devlog/_fin/260826_cursor_responses_gap/070_ultra_mode.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/070_ultra_mode.md rename to devlog/_fin/260826_cursor_responses_gap/070_ultra_mode.md diff --git a/devlog/_plan/260826_cursor_responses_gap/080_stall_corruption_diag.md b/devlog/_fin/260826_cursor_responses_gap/080_stall_corruption_diag.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/080_stall_corruption_diag.md rename to devlog/_fin/260826_cursor_responses_gap/080_stall_corruption_diag.md diff --git a/devlog/_plan/260826_cursor_responses_gap/090_gap8_codex_exec_qa.md b/devlog/_fin/260826_cursor_responses_gap/090_gap8_codex_exec_qa.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/090_gap8_codex_exec_qa.md rename to devlog/_fin/260826_cursor_responses_gap/090_gap8_codex_exec_qa.md diff --git a/devlog/_plan/260826_cursor_responses_gap/100_wire_ndjson_qa.md b/devlog/_fin/260826_cursor_responses_gap/100_wire_ndjson_qa.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/100_wire_ndjson_qa.md rename to devlog/_fin/260826_cursor_responses_gap/100_wire_ndjson_qa.md diff --git a/devlog/_plan/260826_cursor_responses_gap/110_app_route_qa.md b/devlog/_fin/260826_cursor_responses_gap/110_app_route_qa.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/110_app_route_qa.md rename to devlog/_fin/260826_cursor_responses_gap/110_app_route_qa.md diff --git a/devlog/_plan/260826_cursor_responses_gap/120_repetition_breaker.md b/devlog/_fin/260826_cursor_responses_gap/120_repetition_breaker.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/120_repetition_breaker.md rename to devlog/_fin/260826_cursor_responses_gap/120_repetition_breaker.md diff --git a/devlog/_plan/260826_cursor_responses_gap/130_envelope_echo_guard.md b/devlog/_fin/260826_cursor_responses_gap/130_envelope_echo_guard.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/130_envelope_echo_guard.md rename to devlog/_fin/260826_cursor_responses_gap/130_envelope_echo_guard.md diff --git a/devlog/_plan/260826_cursor_responses_gap/140_round2_probe_results.md b/devlog/_fin/260826_cursor_responses_gap/140_round2_probe_results.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/140_round2_probe_results.md rename to devlog/_fin/260826_cursor_responses_gap/140_round2_probe_results.md diff --git a/devlog/_plan/260826_cursor_responses_gap/150_routing_commentary_hallucination.md b/devlog/_fin/260826_cursor_responses_gap/150_routing_commentary_hallucination.md similarity index 100% rename from devlog/_plan/260826_cursor_responses_gap/150_routing_commentary_hallucination.md rename to devlog/_fin/260826_cursor_responses_gap/150_routing_commentary_hallucination.md diff --git a/devlog/_plan/260826_pre_substrate_adoption/010_design.md b/devlog/_fin/260826_pre_substrate_adoption/010_design.md similarity index 100% rename from devlog/_plan/260826_pre_substrate_adoption/010_design.md rename to devlog/_fin/260826_pre_substrate_adoption/010_design.md diff --git a/devlog/_plan/260826_provenance_writer/010_design.md b/devlog/_fin/260826_provenance_writer/010_design.md similarity index 100% rename from devlog/_plan/260826_provenance_writer/010_design.md rename to devlog/_fin/260826_provenance_writer/010_design.md diff --git a/devlog/_plan/260826_restart_codex_linux_survivor/000_repro_and_root_cause.md b/devlog/_fin/260826_restart_codex_linux_survivor/000_repro_and_root_cause.md similarity index 100% rename from devlog/_plan/260826_restart_codex_linux_survivor/000_repro_and_root_cause.md rename to devlog/_fin/260826_restart_codex_linux_survivor/000_repro_and_root_cause.md diff --git a/devlog/_plan/260826_session_lane_bounds/010_design.md b/devlog/_fin/260826_session_lane_bounds/010_design.md similarity index 100% rename from devlog/_plan/260826_session_lane_bounds/010_design.md rename to devlog/_fin/260826_session_lane_bounds/010_design.md diff --git a/devlog/_plan/260826_session_lane_bounds/020_reconnect_followup.md b/devlog/_fin/260826_session_lane_bounds/020_reconnect_followup.md similarity index 100% rename from devlog/_plan/260826_session_lane_bounds/020_reconnect_followup.md rename to devlog/_fin/260826_session_lane_bounds/020_reconnect_followup.md diff --git a/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/010_plan.md b/devlog/_fin/260826_wp7e_presence_driven_oauth_failover/010_plan.md similarity index 100% rename from devlog/_plan/260826_wp7e_presence_driven_oauth_failover/010_plan.md rename to devlog/_fin/260826_wp7e_presence_driven_oauth_failover/010_plan.md diff --git a/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/011_audit_response.md b/devlog/_fin/260826_wp7e_presence_driven_oauth_failover/011_audit_response.md similarity index 100% rename from devlog/_plan/260826_wp7e_presence_driven_oauth_failover/011_audit_response.md rename to devlog/_fin/260826_wp7e_presence_driven_oauth_failover/011_audit_response.md diff --git a/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/020_execution.md b/devlog/_fin/260826_wp7e_presence_driven_oauth_failover/020_execution.md similarity index 100% rename from devlog/_plan/260826_wp7e_presence_driven_oauth_failover/020_execution.md rename to devlog/_fin/260826_wp7e_presence_driven_oauth_failover/020_execution.md diff --git a/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/030_post_merge_f5.md b/devlog/_fin/260826_wp7e_presence_driven_oauth_failover/030_post_merge_f5.md similarity index 100% rename from devlog/_plan/260826_wp7e_presence_driven_oauth_failover/030_post_merge_f5.md rename to devlog/_fin/260826_wp7e_presence_driven_oauth_failover/030_post_merge_f5.md diff --git a/devlog/_fin/260827_igwanu_bug_pr_merge_round/011_wp1_outcome.md b/devlog/_fin/260827_igwanu_bug_pr_merge_round/011_wp1_outcome.md index f5fde1ac427..36ab5a3b092 100644 --- a/devlog/_fin/260827_igwanu_bug_pr_merge_round/011_wp1_outcome.md +++ b/devlog/_fin/260827_igwanu_bug_pr_merge_round/011_wp1_outcome.md @@ -99,6 +99,6 @@ runs the native TypeScript 7.0.2 compiler at ~0.44s for a full typecheck. 2. Review, rebase, and verification of other PRs may proceed in parallel — only the merges serialize. 3. Follow-up outside this round's scope: the same pre-disclosure material exists - in `devlog/_plan/260826_wp7e_presence_driven_oauth_failover/` and + in `devlog/_fin/260826_wp7e_presence_driven_oauth_failover/` and `devlog/_plan/260827_dev_hardening/`. Pre-existing, belongs to other active work streams, needs separate authority. **Escalate; do not silently rewrite.** diff --git a/devlog/_fin/260827_igwanu_bug_pr_merge_round/070_outcome.md b/devlog/_fin/260827_igwanu_bug_pr_merge_round/070_outcome.md index 635eae70a7a..4a830113d3a 100644 --- a/devlog/_fin/260827_igwanu_bug_pr_merge_round/070_outcome.md +++ b/devlog/_fin/260827_igwanu_bug_pr_merge_round/070_outcome.md @@ -96,7 +96,7 @@ incorrect correction is worse than the original error. ## Follow-ups outside this round's scope - The same pre-disclosure OAuth material exists in - `devlog/_plan/260826_wp7e_presence_driven_oauth_failover/` and + `devlog/_fin/260826_wp7e_presence_driven_oauth_failover/` and `devlog/_plan/260827_dev_hardening/`. Pre-existing, other work streams, needs separate authority. **Escalated, not silently rewritten.** - `CL-07 task effectiveness producer > inactivity timeout is bounded for trusted diff --git a/devlog/_plan/260827_kiro_builder_id_profile/010_builder_id_request_scoped_profile.md b/devlog/_fin/260827_kiro_builder_id_profile/010_builder_id_request_scoped_profile.md similarity index 100% rename from devlog/_plan/260827_kiro_builder_id_profile/010_builder_id_request_scoped_profile.md rename to devlog/_fin/260827_kiro_builder_id_profile/010_builder_id_request_scoped_profile.md diff --git a/devlog/_plan/260829_kiro_quota_pool/000_research_problem_and_state.md b/devlog/_fin/260829_kiro_quota_pool/000_research_problem_and_state.md similarity index 98% rename from devlog/_plan/260829_kiro_quota_pool/000_research_problem_and_state.md rename to devlog/_fin/260829_kiro_quota_pool/000_research_problem_and_state.md index 468ac62dee4..3bffbfc1fe4 100644 --- a/devlog/_plan/260829_kiro_quota_pool/000_research_problem_and_state.md +++ b/devlog/_fin/260829_kiro_quota_pool/000_research_problem_and_state.md @@ -1,6 +1,6 @@ # 000 — Kiro quota + pool: problem statement and current state -Unit: `devlog/_plan/260829_kiro_quota_pool/` +Unit: `devlog/_fin/260829_kiro_quota_pool/` Opened: 2026-08-29 Work classes: C3 (quota fetcher, pool selection), C2 (surfaces, docs) diff --git a/devlog/_plan/260829_kiro_quota_pool/001_research_upstream_wire_contract.md b/devlog/_fin/260829_kiro_quota_pool/001_research_upstream_wire_contract.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/001_research_upstream_wire_contract.md rename to devlog/_fin/260829_kiro_quota_pool/001_research_upstream_wire_contract.md diff --git a/devlog/_plan/260829_kiro_quota_pool/002_research_kirolb_headtohead.md b/devlog/_fin/260829_kiro_quota_pool/002_research_kirolb_headtohead.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/002_research_kirolb_headtohead.md rename to devlog/_fin/260829_kiro_quota_pool/002_research_kirolb_headtohead.md diff --git a/devlog/_plan/260829_kiro_quota_pool/003_research_pr_reconciliation.md b/devlog/_fin/260829_kiro_quota_pool/003_research_pr_reconciliation.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/003_research_pr_reconciliation.md rename to devlog/_fin/260829_kiro_quota_pool/003_research_pr_reconciliation.md diff --git a/devlog/_plan/260829_kiro_quota_pool/010_kiro_usage_fetcher.md b/devlog/_fin/260829_kiro_quota_pool/010_kiro_usage_fetcher.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/010_kiro_usage_fetcher.md rename to devlog/_fin/260829_kiro_quota_pool/010_kiro_usage_fetcher.md diff --git a/devlog/_plan/260829_kiro_quota_pool/020_per_account_quota_wiring.md b/devlog/_fin/260829_kiro_quota_pool/020_per_account_quota_wiring.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/020_per_account_quota_wiring.md rename to devlog/_fin/260829_kiro_quota_pool/020_per_account_quota_wiring.md diff --git a/devlog/_plan/260829_kiro_quota_pool/030_quota_aware_pool_selection.md b/devlog/_fin/260829_kiro_quota_pool/030_quota_aware_pool_selection.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/030_quota_aware_pool_selection.md rename to devlog/_fin/260829_kiro_quota_pool/030_quota_aware_pool_selection.md diff --git a/devlog/_plan/260829_kiro_quota_pool/040_surfaces_cli_gui_docs.md b/devlog/_fin/260829_kiro_quota_pool/040_surfaces_cli_gui_docs.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/040_surfaces_cli_gui_docs.md rename to devlog/_fin/260829_kiro_quota_pool/040_surfaces_cli_gui_docs.md diff --git a/devlog/_plan/260829_kiro_quota_pool/050_verification_and_delivery.md b/devlog/_fin/260829_kiro_quota_pool/050_verification_and_delivery.md similarity index 97% rename from devlog/_plan/260829_kiro_quota_pool/050_verification_and_delivery.md rename to devlog/_fin/260829_kiro_quota_pool/050_verification_and_delivery.md index 94ab6a785e0..636369a317e 100644 --- a/devlog/_plan/260829_kiro_quota_pool/050_verification_and_delivery.md +++ b/devlog/_fin/260829_kiro_quota_pool/050_verification_and_delivery.md @@ -55,7 +55,7 @@ restart to seed routing. This unit does neither. - Commits: one per phase (`010`…`040`), plus the devlog unit. - PR against `dev`, full template (Summary / Verification / Checklist), GUI screenshot. - Push with `--no-verify` (user-authorized), merge after CI green. -- Move `devlog/_plan/260829_kiro_quota_pool/` → `devlog/_fin/` at close-out, since the +- Move `devlog/_fin/260829_kiro_quota_pool/` → `devlog/_fin/` at close-out, since the fix will be public by then. ## Security note diff --git a/devlog/_plan/260829_kiro_quota_pool/060_audit_round1_amendments.md b/devlog/_fin/260829_kiro_quota_pool/060_audit_round1_amendments.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/060_audit_round1_amendments.md rename to devlog/_fin/260829_kiro_quota_pool/060_audit_round1_amendments.md diff --git a/devlog/_plan/260829_kiro_quota_pool/070_audit_round2_amendments.md b/devlog/_fin/260829_kiro_quota_pool/070_audit_round2_amendments.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/070_audit_round2_amendments.md rename to devlog/_fin/260829_kiro_quota_pool/070_audit_round2_amendments.md diff --git a/devlog/_plan/260829_kiro_quota_pool/080_head_to_head_result.md b/devlog/_fin/260829_kiro_quota_pool/080_head_to_head_result.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/080_head_to_head_result.md rename to devlog/_fin/260829_kiro_quota_pool/080_head_to_head_result.md diff --git a/devlog/_plan/260829_kiro_quota_pool/090_predispatch_selection.md b/devlog/_fin/260829_kiro_quota_pool/090_predispatch_selection.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/090_predispatch_selection.md rename to devlog/_fin/260829_kiro_quota_pool/090_predispatch_selection.md diff --git a/devlog/_plan/260829_kiro_quota_pool/100_quota_persistence.md b/devlog/_fin/260829_kiro_quota_pool/100_quota_persistence.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/100_quota_persistence.md rename to devlog/_fin/260829_kiro_quota_pool/100_quota_persistence.md diff --git a/devlog/_plan/260829_kiro_quota_pool/110_integrated_verification.md b/devlog/_fin/260829_kiro_quota_pool/110_integrated_verification.md similarity index 100% rename from devlog/_plan/260829_kiro_quota_pool/110_integrated_verification.md rename to devlog/_fin/260829_kiro_quota_pool/110_integrated_verification.md diff --git a/devlog/_plan/260830_models_provider_header/000_baseline_and_roadmap.md b/devlog/_fin/260830_models_provider_header/000_baseline_and_roadmap.md similarity index 100% rename from devlog/_plan/260830_models_provider_header/000_baseline_and_roadmap.md rename to devlog/_fin/260830_models_provider_header/000_baseline_and_roadmap.md diff --git a/devlog/_plan/260830_models_provider_header/010_toggle_basis_and_shrink.md b/devlog/_fin/260830_models_provider_header/010_toggle_basis_and_shrink.md similarity index 100% rename from devlog/_plan/260830_models_provider_header/010_toggle_basis_and_shrink.md rename to devlog/_fin/260830_models_provider_header/010_toggle_basis_and_shrink.md diff --git a/devlog/_plan/260830_models_provider_header/011_rejected_designs.md b/devlog/_fin/260830_models_provider_header/011_rejected_designs.md similarity index 100% rename from devlog/_plan/260830_models_provider_header/011_rejected_designs.md rename to devlog/_fin/260830_models_provider_header/011_rejected_designs.md diff --git a/devlog/_plan/260830_models_provider_header/020_control_affordances.md b/devlog/_fin/260830_models_provider_header/020_control_affordances.md similarity index 100% rename from devlog/_plan/260830_models_provider_header/020_control_affordances.md rename to devlog/_fin/260830_models_provider_header/020_control_affordances.md diff --git a/devlog/_plan/260830_models_provider_header/030_uniform_row_and_hover.md b/devlog/_fin/260830_models_provider_header/030_uniform_row_and_hover.md similarity index 100% rename from devlog/_plan/260830_models_provider_header/030_uniform_row_and_hover.md rename to devlog/_fin/260830_models_provider_header/030_uniform_row_and_hover.md diff --git a/devlog/_plan/260830_models_provider_header/040_cap_cluster_and_occupied_slot.md b/devlog/_fin/260830_models_provider_header/040_cap_cluster_and_occupied_slot.md similarity index 100% rename from devlog/_plan/260830_models_provider_header/040_cap_cluster_and_occupied_slot.md rename to devlog/_fin/260830_models_provider_header/040_cap_cluster_and_occupied_slot.md diff --git a/devlog/_plan/260830_models_provider_header/050_hover_affordance_and_column_gate.md b/devlog/_fin/260830_models_provider_header/050_hover_affordance_and_column_gate.md similarity index 100% rename from devlog/_plan/260830_models_provider_header/050_hover_affordance_and_column_gate.md rename to devlog/_fin/260830_models_provider_header/050_hover_affordance_and_column_gate.md diff --git a/devlog/_plan/260830_models_provider_header/evidence/010-after-ko-1100.png b/devlog/_fin/260830_models_provider_header/evidence/010-after-ko-1100.png similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/010-after-ko-1100.png rename to devlog/_fin/260830_models_provider_header/evidence/010-after-ko-1100.png diff --git a/devlog/_plan/260830_models_provider_header/evidence/010-before-ko-1100.png b/devlog/_fin/260830_models_provider_header/evidence/010-before-ko-1100.png similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/010-before-ko-1100.png rename to devlog/_fin/260830_models_provider_header/evidence/010-before-ko-1100.png diff --git a/devlog/_plan/260830_models_provider_header/evidence/020-after-labeled-switch-ko-1280.png b/devlog/_fin/260830_models_provider_header/evidence/020-after-labeled-switch-ko-1280.png similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/020-after-labeled-switch-ko-1280.png rename to devlog/_fin/260830_models_provider_header/evidence/020-after-labeled-switch-ko-1280.png diff --git a/devlog/_plan/260830_models_provider_header/evidence/020-after-labels.json b/devlog/_fin/260830_models_provider_header/evidence/020-after-labels.json similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/020-after-labels.json rename to devlog/_fin/260830_models_provider_header/evidence/020-after-labels.json diff --git a/devlog/_plan/260830_models_provider_header/evidence/030-baseline.json b/devlog/_fin/260830_models_provider_header/evidence/030-baseline.json similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/030-baseline.json rename to devlog/_fin/260830_models_provider_header/evidence/030-baseline.json diff --git a/devlog/_plan/260830_models_provider_header/evidence/030-geometry-harness.ts b/devlog/_fin/260830_models_provider_header/evidence/030-geometry-harness.ts similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/030-geometry-harness.ts rename to devlog/_fin/260830_models_provider_header/evidence/030-geometry-harness.ts diff --git a/devlog/_plan/260830_models_provider_header/evidence/030-locale-harness.ts b/devlog/_fin/260830_models_provider_header/evidence/030-locale-harness.ts similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/030-locale-harness.ts rename to devlog/_fin/260830_models_provider_header/evidence/030-locale-harness.ts diff --git a/devlog/_plan/260830_models_provider_header/evidence/030-locale-sweep.json b/devlog/_fin/260830_models_provider_header/evidence/030-locale-sweep.json similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/030-locale-sweep.json rename to devlog/_fin/260830_models_provider_header/evidence/030-locale-sweep.json diff --git a/devlog/_plan/260830_models_provider_header/evidence/040-after-cap-slot-ko-1280.png b/devlog/_fin/260830_models_provider_header/evidence/040-after-cap-slot-ko-1280.png similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/040-after-cap-slot-ko-1280.png rename to devlog/_fin/260830_models_provider_header/evidence/040-after-cap-slot-ko-1280.png diff --git a/devlog/_plan/260830_models_provider_header/evidence/040-geometry-harness.ts b/devlog/_fin/260830_models_provider_header/evidence/040-geometry-harness.ts similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/040-geometry-harness.ts rename to devlog/_fin/260830_models_provider_header/evidence/040-geometry-harness.ts diff --git a/devlog/_plan/260830_models_provider_header/evidence/050-state-harness.ts b/devlog/_fin/260830_models_provider_header/evidence/050-state-harness.ts similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/050-state-harness.ts rename to devlog/_fin/260830_models_provider_header/evidence/050-state-harness.ts diff --git a/devlog/_plan/260830_models_provider_header/evidence/wp2-states.json b/devlog/_fin/260830_models_provider_header/evidence/wp2-states.json similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/wp2-states.json rename to devlog/_fin/260830_models_provider_header/evidence/wp2-states.json diff --git a/devlog/_plan/260830_models_provider_header/evidence/wp2-touch.json b/devlog/_fin/260830_models_provider_header/evidence/wp2-touch.json similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/wp2-touch.json rename to devlog/_fin/260830_models_provider_header/evidence/wp2-touch.json diff --git a/devlog/_plan/260830_models_provider_header/evidence/wp3-geometry.json b/devlog/_fin/260830_models_provider_header/evidence/wp3-geometry.json similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/wp3-geometry.json rename to devlog/_fin/260830_models_provider_header/evidence/wp3-geometry.json diff --git a/devlog/_plan/260830_models_provider_header/evidence/wp3-state.json b/devlog/_fin/260830_models_provider_header/evidence/wp3-state.json similarity index 100% rename from devlog/_plan/260830_models_provider_header/evidence/wp3-state.json rename to devlog/_fin/260830_models_provider_header/evidence/wp3-state.json diff --git a/devlog/_plan/260901_merge_train_round3/000_plan.md b/devlog/_fin/260901_merge_train_round3/000_plan.md similarity index 100% rename from devlog/_plan/260901_merge_train_round3/000_plan.md rename to devlog/_fin/260901_merge_train_round3/000_plan.md diff --git a/devlog/_plan/260901_merge_train_round3/002_audit_round1_synthesis.md b/devlog/_fin/260901_merge_train_round3/002_audit_round1_synthesis.md similarity index 100% rename from devlog/_plan/260901_merge_train_round3/002_audit_round1_synthesis.md rename to devlog/_fin/260901_merge_train_round3/002_audit_round1_synthesis.md diff --git a/devlog/_plan/260901_merge_train_round3/010_wp1_3114_docs_devlog.md b/devlog/_fin/260901_merge_train_round3/010_wp1_3114_docs_devlog.md similarity index 100% rename from devlog/_plan/260901_merge_train_round3/010_wp1_3114_docs_devlog.md rename to devlog/_fin/260901_merge_train_round3/010_wp1_3114_docs_devlog.md diff --git a/devlog/_plan/260901_merge_train_round3/011_wp1_outcome.md b/devlog/_fin/260901_merge_train_round3/011_wp1_outcome.md similarity index 100% rename from devlog/_plan/260901_merge_train_round3/011_wp1_outcome.md rename to devlog/_fin/260901_merge_train_round3/011_wp1_outcome.md diff --git a/devlog/_plan/260901_merge_train_round3/020_wp2_3122_provider_patch_fakeip.md b/devlog/_fin/260901_merge_train_round3/020_wp2_3122_provider_patch_fakeip.md similarity index 100% rename from devlog/_plan/260901_merge_train_round3/020_wp2_3122_provider_patch_fakeip.md rename to devlog/_fin/260901_merge_train_round3/020_wp2_3122_provider_patch_fakeip.md diff --git a/devlog/_plan/260901_merge_train_round3/021_wp2_security_review.md b/devlog/_fin/260901_merge_train_round3/021_wp2_security_review.md similarity index 100% rename from devlog/_plan/260901_merge_train_round3/021_wp2_security_review.md rename to devlog/_fin/260901_merge_train_round3/021_wp2_security_review.md diff --git a/devlog/_plan/260901_merge_train_round3/030_wp3_3104_service_and_closeouts.md b/devlog/_fin/260901_merge_train_round3/030_wp3_3104_service_and_closeouts.md similarity index 100% rename from devlog/_plan/260901_merge_train_round3/030_wp3_3104_service_and_closeouts.md rename to devlog/_fin/260901_merge_train_round3/030_wp3_3104_service_and_closeouts.md diff --git a/devlog/_plan/260901_merge_train_round3/031_wp3_outcome.md b/devlog/_fin/260901_merge_train_round3/031_wp3_outcome.md similarity index 100% rename from devlog/_plan/260901_merge_train_round3/031_wp3_outcome.md rename to devlog/_fin/260901_merge_train_round3/031_wp3_outcome.md diff --git a/devlog/_plan/260901_merge_train_round3/040_wp4_3042_pid_probe.md b/devlog/_fin/260901_merge_train_round3/040_wp4_3042_pid_probe.md similarity index 100% rename from devlog/_plan/260901_merge_train_round3/040_wp4_3042_pid_probe.md rename to devlog/_fin/260901_merge_train_round3/040_wp4_3042_pid_probe.md diff --git a/devlog/_plan/260901_merge_train_round3/050_wp5_close_3077_rebase_3109_3112.md b/devlog/_fin/260901_merge_train_round3/050_wp5_close_3077_rebase_3109_3112.md similarity index 100% rename from devlog/_plan/260901_merge_train_round3/050_wp5_close_3077_rebase_3109_3112.md rename to devlog/_fin/260901_merge_train_round3/050_wp5_close_3077_rebase_3109_3112.md diff --git a/devlog/_plan/260901_merge_train_round3/051_wp5_outcome.md b/devlog/_fin/260901_merge_train_round3/051_wp5_outcome.md similarity index 100% rename from devlog/_plan/260901_merge_train_round3/051_wp5_outcome.md rename to devlog/_fin/260901_merge_train_round3/051_wp5_outcome.md diff --git a/devlog/_plan/260901_merge_train_round3/060_wp7_websocket_refresh_flake.md b/devlog/_fin/260901_merge_train_round3/060_wp7_websocket_refresh_flake.md similarity index 100% rename from devlog/_plan/260901_merge_train_round3/060_wp7_websocket_refresh_flake.md rename to devlog/_fin/260901_merge_train_round3/060_wp7_websocket_refresh_flake.md diff --git a/devlog/_plan/260902_bug_label_drawdown/000_plan.md b/devlog/_fin/260902_bug_label_drawdown/000_plan.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/000_plan.md rename to devlog/_fin/260902_bug_label_drawdown/000_plan.md diff --git a/devlog/_plan/260902_bug_label_drawdown/010_phase1.md b/devlog/_fin/260902_bug_label_drawdown/010_phase1.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/010_phase1.md rename to devlog/_fin/260902_bug_label_drawdown/010_phase1.md diff --git a/devlog/_plan/260902_bug_label_drawdown/011_bd1_landing.md b/devlog/_fin/260902_bug_label_drawdown/011_bd1_landing.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/011_bd1_landing.md rename to devlog/_fin/260902_bug_label_drawdown/011_bd1_landing.md diff --git a/devlog/_plan/260902_bug_label_drawdown/020_phase2.md b/devlog/_fin/260902_bug_label_drawdown/020_phase2.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/020_phase2.md rename to devlog/_fin/260902_bug_label_drawdown/020_phase2.md diff --git a/devlog/_plan/260902_bug_label_drawdown/021_bd2_landing.md b/devlog/_fin/260902_bug_label_drawdown/021_bd2_landing.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/021_bd2_landing.md rename to devlog/_fin/260902_bug_label_drawdown/021_bd2_landing.md diff --git a/devlog/_plan/260902_bug_label_drawdown/030_phase3.md b/devlog/_fin/260902_bug_label_drawdown/030_phase3.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/030_phase3.md rename to devlog/_fin/260902_bug_label_drawdown/030_phase3.md diff --git a/devlog/_plan/260902_bug_label_drawdown/031_bd3_landing.md b/devlog/_fin/260902_bug_label_drawdown/031_bd3_landing.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/031_bd3_landing.md rename to devlog/_fin/260902_bug_label_drawdown/031_bd3_landing.md diff --git a/devlog/_plan/260902_bug_label_drawdown/040_phase4.md b/devlog/_fin/260902_bug_label_drawdown/040_phase4.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/040_phase4.md rename to devlog/_fin/260902_bug_label_drawdown/040_phase4.md diff --git a/devlog/_plan/260902_bug_label_drawdown/041_bd4_landing.md b/devlog/_fin/260902_bug_label_drawdown/041_bd4_landing.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/041_bd4_landing.md rename to devlog/_fin/260902_bug_label_drawdown/041_bd4_landing.md diff --git a/devlog/_plan/260902_bug_label_drawdown/050_bd5_replan.md b/devlog/_fin/260902_bug_label_drawdown/050_bd5_replan.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/050_bd5_replan.md rename to devlog/_fin/260902_bug_label_drawdown/050_bd5_replan.md diff --git a/devlog/_plan/260902_bug_label_drawdown/050_phase5.md b/devlog/_fin/260902_bug_label_drawdown/050_phase5.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/050_phase5.md rename to devlog/_fin/260902_bug_label_drawdown/050_phase5.md diff --git a/devlog/_plan/260902_bug_label_drawdown/051_i3141.md b/devlog/_fin/260902_bug_label_drawdown/051_i3141.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/051_i3141.md rename to devlog/_fin/260902_bug_label_drawdown/051_i3141.md diff --git a/devlog/_plan/260902_bug_label_drawdown/052_i3152.md b/devlog/_fin/260902_bug_label_drawdown/052_i3152.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/052_i3152.md rename to devlog/_fin/260902_bug_label_drawdown/052_i3152.md diff --git a/devlog/_plan/260902_bug_label_drawdown/053_i3136.md b/devlog/_fin/260902_bug_label_drawdown/053_i3136.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/053_i3136.md rename to devlog/_fin/260902_bug_label_drawdown/053_i3136.md diff --git a/devlog/_plan/260902_bug_label_drawdown/054_i3150.md b/devlog/_fin/260902_bug_label_drawdown/054_i3150.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/054_i3150.md rename to devlog/_fin/260902_bug_label_drawdown/054_i3150.md diff --git a/devlog/_plan/260902_bug_label_drawdown/055_i3155.md b/devlog/_fin/260902_bug_label_drawdown/055_i3155.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/055_i3155.md rename to devlog/_fin/260902_bug_label_drawdown/055_i3155.md diff --git a/devlog/_plan/260902_bug_label_drawdown/056_i1419.md b/devlog/_fin/260902_bug_label_drawdown/056_i1419.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/056_i1419.md rename to devlog/_fin/260902_bug_label_drawdown/056_i1419.md diff --git a/devlog/_plan/260902_bug_label_drawdown/057_i2999.md b/devlog/_fin/260902_bug_label_drawdown/057_i2999.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/057_i2999.md rename to devlog/_fin/260902_bug_label_drawdown/057_i2999.md diff --git a/devlog/_plan/260902_bug_label_drawdown/058_i2813.md b/devlog/_fin/260902_bug_label_drawdown/058_i2813.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/058_i2813.md rename to devlog/_fin/260902_bug_label_drawdown/058_i2813.md diff --git a/devlog/_plan/260902_bug_label_drawdown/059_i1527.md b/devlog/_fin/260902_bug_label_drawdown/059_i1527.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/059_i1527.md rename to devlog/_fin/260902_bug_label_drawdown/059_i1527.md diff --git a/devlog/_plan/260902_bug_label_drawdown/060_phase6.md b/devlog/_fin/260902_bug_label_drawdown/060_phase6.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/060_phase6.md rename to devlog/_fin/260902_bug_label_drawdown/060_phase6.md diff --git a/devlog/_plan/260902_bug_label_drawdown/061_p3193.md b/devlog/_fin/260902_bug_label_drawdown/061_p3193.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/061_p3193.md rename to devlog/_fin/260902_bug_label_drawdown/061_p3193.md diff --git a/devlog/_plan/260902_bug_label_drawdown/062_p3193_landing.md b/devlog/_fin/260902_bug_label_drawdown/062_p3193_landing.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/062_p3193_landing.md rename to devlog/_fin/260902_bug_label_drawdown/062_p3193_landing.md diff --git a/devlog/_plan/260902_bug_label_drawdown/063_i3217.md b/devlog/_fin/260902_bug_label_drawdown/063_i3217.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/063_i3217.md rename to devlog/_fin/260902_bug_label_drawdown/063_i3217.md diff --git a/devlog/_plan/260902_bug_label_drawdown/064_i3217_landing.md b/devlog/_fin/260902_bug_label_drawdown/064_i3217_landing.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/064_i3217_landing.md rename to devlog/_fin/260902_bug_label_drawdown/064_i3217_landing.md diff --git a/devlog/_plan/260902_bug_label_drawdown/070_regaudit.md b/devlog/_fin/260902_bug_label_drawdown/070_regaudit.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/070_regaudit.md rename to devlog/_fin/260902_bug_label_drawdown/070_regaudit.md diff --git a/devlog/_plan/260902_bug_label_drawdown/071_regaudit_landing.md b/devlog/_fin/260902_bug_label_drawdown/071_regaudit_landing.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/071_regaudit_landing.md rename to devlog/_fin/260902_bug_label_drawdown/071_regaudit_landing.md diff --git a/devlog/_plan/260902_bug_label_drawdown/072_regaudit2.md b/devlog/_fin/260902_bug_label_drawdown/072_regaudit2.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/072_regaudit2.md rename to devlog/_fin/260902_bug_label_drawdown/072_regaudit2.md diff --git a/devlog/_plan/260902_bug_label_drawdown/080_p3226.md b/devlog/_fin/260902_bug_label_drawdown/080_p3226.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/080_p3226.md rename to devlog/_fin/260902_bug_label_drawdown/080_p3226.md diff --git a/devlog/_plan/260902_bug_label_drawdown/081_p3226_landing.md b/devlog/_fin/260902_bug_label_drawdown/081_p3226_landing.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/081_p3226_landing.md rename to devlog/_fin/260902_bug_label_drawdown/081_p3226_landing.md diff --git a/devlog/_plan/260902_bug_label_drawdown/082_p3227.md b/devlog/_fin/260902_bug_label_drawdown/082_p3227.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/082_p3227.md rename to devlog/_fin/260902_bug_label_drawdown/082_p3227.md diff --git a/devlog/_plan/260902_bug_label_drawdown/083_p3227_landing.md b/devlog/_fin/260902_bug_label_drawdown/083_p3227_landing.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/083_p3227_landing.md rename to devlog/_fin/260902_bug_label_drawdown/083_p3227_landing.md diff --git a/devlog/_plan/260902_bug_label_drawdown/084_p3228.md b/devlog/_fin/260902_bug_label_drawdown/084_p3228.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/084_p3228.md rename to devlog/_fin/260902_bug_label_drawdown/084_p3228.md diff --git a/devlog/_plan/260902_bug_label_drawdown/085_p3228_landing.md b/devlog/_fin/260902_bug_label_drawdown/085_p3228_landing.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/085_p3228_landing.md rename to devlog/_fin/260902_bug_label_drawdown/085_p3228_landing.md diff --git a/devlog/_plan/260902_bug_label_drawdown/086_p3229.md b/devlog/_fin/260902_bug_label_drawdown/086_p3229.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/086_p3229.md rename to devlog/_fin/260902_bug_label_drawdown/086_p3229.md diff --git a/devlog/_plan/260902_bug_label_drawdown/087_p3229_landing.md b/devlog/_fin/260902_bug_label_drawdown/087_p3229_landing.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/087_p3229_landing.md rename to devlog/_fin/260902_bug_label_drawdown/087_p3229_landing.md diff --git a/devlog/_plan/260902_bug_label_drawdown/088_r3239.md b/devlog/_fin/260902_bug_label_drawdown/088_r3239.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/088_r3239.md rename to devlog/_fin/260902_bug_label_drawdown/088_r3239.md diff --git a/devlog/_plan/260902_bug_label_drawdown/089_p3232.md b/devlog/_fin/260902_bug_label_drawdown/089_p3232.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/089_p3232.md rename to devlog/_fin/260902_bug_label_drawdown/089_p3232.md diff --git a/devlog/_plan/260902_bug_label_drawdown/090_regaudit3.md b/devlog/_fin/260902_bug_label_drawdown/090_regaudit3.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/090_regaudit3.md rename to devlog/_fin/260902_bug_label_drawdown/090_regaudit3.md diff --git a/devlog/_plan/260902_bug_label_drawdown/091_rv3239.md b/devlog/_fin/260902_bug_label_drawdown/091_rv3239.md similarity index 100% rename from devlog/_plan/260902_bug_label_drawdown/091_rv3239.md rename to devlog/_fin/260902_bug_label_drawdown/091_rv3239.md diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/000_research.md b/devlog/_fin/260902_cursor_bundle_effort_table/000_research.md similarity index 100% rename from devlog/_plan/260902_cursor_bundle_effort_table/000_research.md rename to devlog/_fin/260902_cursor_bundle_effort_table/000_research.md diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/001_bundle_protocol.md b/devlog/_fin/260902_cursor_bundle_effort_table/001_bundle_protocol.md similarity index 100% rename from devlog/_plan/260902_cursor_bundle_effort_table/001_bundle_protocol.md rename to devlog/_fin/260902_cursor_bundle_effort_table/001_bundle_protocol.md diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/005_audit_round1.md b/devlog/_fin/260902_cursor_bundle_effort_table/005_audit_round1.md similarity index 100% rename from devlog/_plan/260902_cursor_bundle_effort_table/005_audit_round1.md rename to devlog/_fin/260902_cursor_bundle_effort_table/005_audit_round1.md diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/010_wp1_bundle_effort_table.md b/devlog/_fin/260902_cursor_bundle_effort_table/010_wp1_bundle_effort_table.md similarity index 100% rename from devlog/_plan/260902_cursor_bundle_effort_table/010_wp1_bundle_effort_table.md rename to devlog/_fin/260902_cursor_bundle_effort_table/010_wp1_bundle_effort_table.md diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/020_wp2_models_max_output.md b/devlog/_fin/260902_cursor_bundle_effort_table/020_wp2_models_max_output.md similarity index 100% rename from devlog/_plan/260902_cursor_bundle_effort_table/020_wp2_models_max_output.md rename to devlog/_fin/260902_cursor_bundle_effort_table/020_wp2_models_max_output.md diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md b/devlog/_fin/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md similarity index 99% rename from devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md rename to devlog/_fin/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md index ae113988fe2..c695c70ce52 100644 --- a/devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md +++ b/devlog/_fin/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md @@ -28,7 +28,7 @@ Amendment (d): `tableLess` in the status route uses `predictCursorEffort(id, tab --- -No files were modified. The untracked `devlog/_plan/260902_cursor_bundle_effort_table/` appeared concurrently and was left untouched. No tests were run. +No files were modified. The untracked `devlog/_fin/260902_cursor_bundle_effort_table/` appeared concurrently and was left untouched. No tests were run. ## Recommendation diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/040_wp4_gui_provenance_hint.md b/devlog/_fin/260902_cursor_bundle_effort_table/040_wp4_gui_provenance_hint.md similarity index 100% rename from devlog/_plan/260902_cursor_bundle_effort_table/040_wp4_gui_provenance_hint.md rename to devlog/_fin/260902_cursor_bundle_effort_table/040_wp4_gui_provenance_hint.md diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/041_wp4_screenshot.png b/devlog/_fin/260902_cursor_bundle_effort_table/041_wp4_screenshot.png similarity index 100% rename from devlog/_plan/260902_cursor_bundle_effort_table/041_wp4_screenshot.png rename to devlog/_fin/260902_cursor_bundle_effort_table/041_wp4_screenshot.png diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/050_wp5_claude_id_normalizer.md b/devlog/_fin/260902_cursor_bundle_effort_table/050_wp5_claude_id_normalizer.md similarity index 100% rename from devlog/_plan/260902_cursor_bundle_effort_table/050_wp5_claude_id_normalizer.md rename to devlog/_fin/260902_cursor_bundle_effort_table/050_wp5_claude_id_normalizer.md diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/060_wp6_guide.md b/devlog/_fin/260902_cursor_bundle_effort_table/060_wp6_guide.md similarity index 100% rename from devlog/_plan/260902_cursor_bundle_effort_table/060_wp6_guide.md rename to devlog/_fin/260902_cursor_bundle_effort_table/060_wp6_guide.md diff --git a/devlog/_plan/260902_cursor_integrations_tab/000_research.md b/devlog/_fin/260902_cursor_integrations_tab/000_research.md similarity index 100% rename from devlog/_plan/260902_cursor_integrations_tab/000_research.md rename to devlog/_fin/260902_cursor_integrations_tab/000_research.md diff --git a/devlog/_plan/260902_cursor_integrations_tab/005_audit_round1.md b/devlog/_fin/260902_cursor_integrations_tab/005_audit_round1.md similarity index 100% rename from devlog/_plan/260902_cursor_integrations_tab/005_audit_round1.md rename to devlog/_fin/260902_cursor_integrations_tab/005_audit_round1.md diff --git a/devlog/_plan/260902_cursor_integrations_tab/006_roadmap_lock.md b/devlog/_fin/260902_cursor_integrations_tab/006_roadmap_lock.md similarity index 100% rename from devlog/_plan/260902_cursor_integrations_tab/006_roadmap_lock.md rename to devlog/_fin/260902_cursor_integrations_tab/006_roadmap_lock.md diff --git a/devlog/_plan/260902_cursor_integrations_tab/007_audit_round1_reviewer.md b/devlog/_fin/260902_cursor_integrations_tab/007_audit_round1_reviewer.md similarity index 100% rename from devlog/_plan/260902_cursor_integrations_tab/007_audit_round1_reviewer.md rename to devlog/_fin/260902_cursor_integrations_tab/007_audit_round1_reviewer.md diff --git a/devlog/_plan/260902_cursor_integrations_tab/010_layer1_server_status.md b/devlog/_fin/260902_cursor_integrations_tab/010_layer1_server_status.md similarity index 100% rename from devlog/_plan/260902_cursor_integrations_tab/010_layer1_server_status.md rename to devlog/_fin/260902_cursor_integrations_tab/010_layer1_server_status.md diff --git a/devlog/_plan/260902_cursor_integrations_tab/020_layer2_gui_tab.md b/devlog/_fin/260902_cursor_integrations_tab/020_layer2_gui_tab.md similarity index 100% rename from devlog/_plan/260902_cursor_integrations_tab/020_layer2_gui_tab.md rename to devlog/_fin/260902_cursor_integrations_tab/020_layer2_gui_tab.md diff --git a/devlog/_plan/260902_cursor_integrations_tab/030_layer3_docs.md b/devlog/_fin/260902_cursor_integrations_tab/030_layer3_docs.md similarity index 100% rename from devlog/_plan/260902_cursor_integrations_tab/030_layer3_docs.md rename to devlog/_fin/260902_cursor_integrations_tab/030_layer3_docs.md diff --git a/devlog/_plan/260902_cursor_integrations_tab/040_publish_stack.md b/devlog/_fin/260902_cursor_integrations_tab/040_publish_stack.md similarity index 100% rename from devlog/_plan/260902_cursor_integrations_tab/040_publish_stack.md rename to devlog/_fin/260902_cursor_integrations_tab/040_publish_stack.md diff --git a/devlog/_plan/260902_cursor_integrations_tab/assets/cursor-overview-card.png b/devlog/_fin/260902_cursor_integrations_tab/assets/cursor-overview-card.png similarity index 100% rename from devlog/_plan/260902_cursor_integrations_tab/assets/cursor-overview-card.png rename to devlog/_fin/260902_cursor_integrations_tab/assets/cursor-overview-card.png diff --git a/devlog/_plan/260902_cursor_integrations_tab/assets/cursor-tab-detected.png b/devlog/_fin/260902_cursor_integrations_tab/assets/cursor-tab-detected.png similarity index 100% rename from devlog/_plan/260902_cursor_integrations_tab/assets/cursor-tab-detected.png rename to devlog/_fin/260902_cursor_integrations_tab/assets/cursor-tab-detected.png diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/000_scope.md b/devlog/_fin/260902_nonbug_adoption_backlog/000_scope.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/000_scope.md rename to devlog/_fin/260902_nonbug_adoption_backlog/000_scope.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/010_wp1_adaptive_reasoning_effort.md b/devlog/_fin/260902_nonbug_adoption_backlog/010_wp1_adaptive_reasoning_effort.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/010_wp1_adaptive_reasoning_effort.md rename to devlog/_fin/260902_nonbug_adoption_backlog/010_wp1_adaptive_reasoning_effort.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/011_wp1_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/011_wp1_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/011_wp1_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/011_wp1_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/020_wp2_outbound_body_guard.md b/devlog/_fin/260902_nonbug_adoption_backlog/020_wp2_outbound_body_guard.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/020_wp2_outbound_body_guard.md rename to devlog/_fin/260902_nonbug_adoption_backlog/020_wp2_outbound_body_guard.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/021_wp2_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/021_wp2_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/021_wp2_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/021_wp2_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/030_wp3_compaction_provider.md b/devlog/_fin/260902_nonbug_adoption_backlog/030_wp3_compaction_provider.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/030_wp3_compaction_provider.md rename to devlog/_fin/260902_nonbug_adoption_backlog/030_wp3_compaction_provider.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/031_wp3_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/031_wp3_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/031_wp3_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/031_wp3_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/040_wp4_retain_models.md b/devlog/_fin/260902_nonbug_adoption_backlog/040_wp4_retain_models.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/040_wp4_retain_models.md rename to devlog/_fin/260902_nonbug_adoption_backlog/040_wp4_retain_models.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/041_wp4_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/041_wp4_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/041_wp4_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/041_wp4_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/050_wp5_xai_imagine_carry.md b/devlog/_fin/260902_nonbug_adoption_backlog/050_wp5_xai_imagine_carry.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/050_wp5_xai_imagine_carry.md rename to devlog/_fin/260902_nonbug_adoption_backlog/050_wp5_xai_imagine_carry.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/051_wp5_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/051_wp5_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/051_wp5_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/051_wp5_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/060_wp6_authless_desktop.md b/devlog/_fin/260902_nonbug_adoption_backlog/060_wp6_authless_desktop.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/060_wp6_authless_desktop.md rename to devlog/_fin/260902_nonbug_adoption_backlog/060_wp6_authless_desktop.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/061_wp6_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/061_wp6_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/061_wp6_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/061_wp6_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/070_wp7_shim_free_token.md b/devlog/_fin/260902_nonbug_adoption_backlog/070_wp7_shim_free_token.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/070_wp7_shim_free_token.md rename to devlog/_fin/260902_nonbug_adoption_backlog/070_wp7_shim_free_token.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/071_wp7_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/071_wp7_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/071_wp7_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/071_wp7_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/080_wp8_windows_proxy_auto.md b/devlog/_fin/260902_nonbug_adoption_backlog/080_wp8_windows_proxy_auto.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/080_wp8_windows_proxy_auto.md rename to devlog/_fin/260902_nonbug_adoption_backlog/080_wp8_windows_proxy_auto.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/081_wp8_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/081_wp8_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/081_wp8_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/081_wp8_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/090_wp9_keychain_keys.md b/devlog/_fin/260902_nonbug_adoption_backlog/090_wp9_keychain_keys.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/090_wp9_keychain_keys.md rename to devlog/_fin/260902_nonbug_adoption_backlog/090_wp9_keychain_keys.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/091_wp9_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/091_wp9_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/091_wp9_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/091_wp9_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/100_wp10_display_names_carry.md b/devlog/_fin/260902_nonbug_adoption_backlog/100_wp10_display_names_carry.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/100_wp10_display_names_carry.md rename to devlog/_fin/260902_nonbug_adoption_backlog/100_wp10_display_names_carry.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/101_wp10_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/101_wp10_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/101_wp10_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/101_wp10_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/110_wp11_antigravity_account_quota.md b/devlog/_fin/260902_nonbug_adoption_backlog/110_wp11_antigravity_account_quota.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/110_wp11_antigravity_account_quota.md rename to devlog/_fin/260902_nonbug_adoption_backlog/110_wp11_antigravity_account_quota.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/111_wp11_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/111_wp11_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/111_wp11_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/111_wp11_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/120_wp12_oauth_pool_capability.md b/devlog/_fin/260902_nonbug_adoption_backlog/120_wp12_oauth_pool_capability.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/120_wp12_oauth_pool_capability.md rename to devlog/_fin/260902_nonbug_adoption_backlog/120_wp12_oauth_pool_capability.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/121_wp12_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/121_wp12_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/121_wp12_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/121_wp12_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md b/devlog/_fin/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md rename to devlog/_fin/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/131_wp13_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/131_wp13_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/131_wp13_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/131_wp13_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/140_wp14_upstream_ws_carry.md b/devlog/_fin/260902_nonbug_adoption_backlog/140_wp14_upstream_ws_carry.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/140_wp14_upstream_ws_carry.md rename to devlog/_fin/260902_nonbug_adoption_backlog/140_wp14_upstream_ws_carry.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/141_wp14_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/141_wp14_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/141_wp14_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/141_wp14_audit_r1_synthesis.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/150_wp15_plaintext_v2_disposition.md b/devlog/_fin/260902_nonbug_adoption_backlog/150_wp15_plaintext_v2_disposition.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/150_wp15_plaintext_v2_disposition.md rename to devlog/_fin/260902_nonbug_adoption_backlog/150_wp15_plaintext_v2_disposition.md diff --git a/devlog/_plan/260902_nonbug_adoption_backlog/151_wp15_audit_r1_synthesis.md b/devlog/_fin/260902_nonbug_adoption_backlog/151_wp15_audit_r1_synthesis.md similarity index 100% rename from devlog/_plan/260902_nonbug_adoption_backlog/151_wp15_audit_r1_synthesis.md rename to devlog/_fin/260902_nonbug_adoption_backlog/151_wp15_audit_r1_synthesis.md diff --git a/devlog/_fin/260902_windows_ci_release/070_outcome.md b/devlog/_fin/260902_windows_ci_release/070_outcome.md index a0791df4ea9..707161b1505 100644 --- a/devlog/_fin/260902_windows_ci_release/070_outcome.md +++ b/devlog/_fin/260902_windows_ci_release/070_outcome.md @@ -33,7 +33,7 @@ fuck-powershell: 56e1801, 2f2107d — 87→90 cases, graph 313/643, validate OK. ## Regression audit (wp4) -Reused `devlog/_plan/260902_bug_label_drawdown/071_regaudit_landing.md` (four reviewers, +Reused `devlog/_fin/260902_bug_label_drawdown/071_regaudit_landing.md` (four reviewers, no regression main..dev at 5bc6939d8) plus the Windows repair reviews above for the delta since. ## Release (wp5) diff --git a/devlog/_plan/260904_flagship_native_always_visible/000_research.md b/devlog/_fin/260904_flagship_native_always_visible/000_research.md similarity index 100% rename from devlog/_plan/260904_flagship_native_always_visible/000_research.md rename to devlog/_fin/260904_flagship_native_always_visible/000_research.md diff --git a/devlog/_plan/260904_flagship_native_always_visible/005_audit_synthesis.md b/devlog/_fin/260904_flagship_native_always_visible/005_audit_synthesis.md similarity index 100% rename from devlog/_plan/260904_flagship_native_always_visible/005_audit_synthesis.md rename to devlog/_fin/260904_flagship_native_always_visible/005_audit_synthesis.md diff --git a/devlog/_plan/260904_flagship_native_always_visible/010_wp2_ungate.md b/devlog/_fin/260904_flagship_native_always_visible/010_wp2_ungate.md similarity index 100% rename from devlog/_plan/260904_flagship_native_always_visible/010_wp2_ungate.md rename to devlog/_fin/260904_flagship_native_always_visible/010_wp2_ungate.md diff --git a/devlog/_plan/260904_flagship_native_always_visible/020_wp3_landing.md b/devlog/_fin/260904_flagship_native_always_visible/020_wp3_landing.md similarity index 100% rename from devlog/_plan/260904_flagship_native_always_visible/020_wp3_landing.md rename to devlog/_fin/260904_flagship_native_always_visible/020_wp3_landing.md diff --git a/devlog/_fin/260904_priority65_closeout/040_wp5_reset_credit_identity.md b/devlog/_fin/260904_priority65_closeout/040_wp5_reset_credit_identity.md index 97db7fc90bd..77dc8ce2c5e 100644 --- a/devlog/_fin/260904_priority65_closeout/040_wp5_reset_credit_identity.md +++ b/devlog/_fin/260904_priority65_closeout/040_wp5_reset_credit_identity.md @@ -39,7 +39,7 @@ $ rg -n 'openResetCreditOperation|settleResetCreditOperation|markResetCreditOper (출력 없음) $ rg -n 'reset-credit-operation-ledger' -g '!node_modules' . -devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md:6 +devlog/_fin/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md:6 tests/codex-reset-credit-operation-ledger.test.ts:24 ``` @@ -60,7 +60,7 @@ legacy 행 승격 + 부분 실패 롤백을 넣지 않는다. 이건 "언젠가 **(4) 별도 devlog가 이 원장을 "unused"로 명시하고 재사용을 계획한다.** -`devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md:6`: +`devlog/_fin/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md:6`: > An unused #657 ledger (`reset-credit-operation-ledger.ts`, kinds `recovery|manual`) exists. diff --git a/devlog/_plan/260905_astra_subagent_roster/000_plan.md b/devlog/_fin/260905_astra_subagent_roster/000_plan.md similarity index 100% rename from devlog/_plan/260905_astra_subagent_roster/000_plan.md rename to devlog/_fin/260905_astra_subagent_roster/000_plan.md diff --git a/devlog/_plan/260905_astra_subagent_roster/010_roster_upgrade.md b/devlog/_fin/260905_astra_subagent_roster/010_roster_upgrade.md similarity index 100% rename from devlog/_plan/260905_astra_subagent_roster/010_roster_upgrade.md rename to devlog/_fin/260905_astra_subagent_roster/010_roster_upgrade.md diff --git a/devlog/_plan/260905_http_upstream_ws_parity/000_plan.md b/devlog/_fin/260905_http_upstream_ws_parity/000_plan.md similarity index 100% rename from devlog/_plan/260905_http_upstream_ws_parity/000_plan.md rename to devlog/_fin/260905_http_upstream_ws_parity/000_plan.md diff --git a/devlog/_plan/260905_http_upstream_ws_parity/001_source_contract.md b/devlog/_fin/260905_http_upstream_ws_parity/001_source_contract.md similarity index 100% rename from devlog/_plan/260905_http_upstream_ws_parity/001_source_contract.md rename to devlog/_fin/260905_http_upstream_ws_parity/001_source_contract.md diff --git a/devlog/_plan/260905_http_upstream_ws_parity/002_audit_synthesis.md b/devlog/_fin/260905_http_upstream_ws_parity/002_audit_synthesis.md similarity index 91% rename from devlog/_plan/260905_http_upstream_ws_parity/002_audit_synthesis.md rename to devlog/_fin/260905_http_upstream_ws_parity/002_audit_synthesis.md index 4fc60e5cd87..a5575a085b5 100644 --- a/devlog/_plan/260905_http_upstream_ws_parity/002_audit_synthesis.md +++ b/devlog/_fin/260905_http_upstream_ws_parity/002_audit_synthesis.md @@ -10,7 +10,7 @@ First independent verdict: FAIL, four High and one Medium planning blockers. No No blockers were dismissed for convenience. Re-review is limited to these amendments and their interaction with existing retry/cleanup boundaries. Final resolution remains pending until the reviewer returns. -Additional baseline checks: direct `/Users/jun/.bun/bin/bun x --no-install tsc --noEmit` passed; `/Users/jun/.bun/bin/bun scripts/privacy-scan.ts` passed; `gitleaks dir devlog/_plan/260905_http_upstream_ws_parity --no-banner --redact` found no leaks. `bun run` aliases initially encountered the intentionally uninstalled package-local Bun stub after `--ignore-scripts`; direct installed Bun executes the same checker without invoking package install scripts or changing the live install. +Additional baseline checks: direct `/Users/jun/.bun/bin/bun x --no-install tsc --noEmit` passed; `/Users/jun/.bun/bin/bun scripts/privacy-scan.ts` passed; `gitleaks dir devlog/_fin/260905_http_upstream_ws_parity --no-banner --redact` found no leaks. `bun run` aliases initially encountered the intentionally uninstalled package-local Bun stub after `--ignore-scripts`; direct installed Bun executes the same checker without invoking package install scripts or changing the live install. Second audit: FAIL with three bounded amendments. Accepted missing preparation-stage placement and null lease fallback; both are fixed in 020, including final-byte-cap tests. Accepted unsupported nested quota/promo projection: 010 now projects only the inspected native event contract, while unknown fields remain in the bounded original event. Header-family projection now agrees with the shared safe-header owner. diff --git a/devlog/_plan/260905_http_upstream_ws_parity/003_roadmap_outcome.md b/devlog/_fin/260905_http_upstream_ws_parity/003_roadmap_outcome.md similarity index 100% rename from devlog/_plan/260905_http_upstream_ws_parity/003_roadmap_outcome.md rename to devlog/_fin/260905_http_upstream_ws_parity/003_roadmap_outcome.md diff --git a/devlog/_plan/260905_http_upstream_ws_parity/010_protocol.md b/devlog/_fin/260905_http_upstream_ws_parity/010_protocol.md similarity index 100% rename from devlog/_plan/260905_http_upstream_ws_parity/010_protocol.md rename to devlog/_fin/260905_http_upstream_ws_parity/010_protocol.md diff --git a/devlog/_plan/260905_http_upstream_ws_parity/011_protocol_build.md b/devlog/_fin/260905_http_upstream_ws_parity/011_protocol_build.md similarity index 100% rename from devlog/_plan/260905_http_upstream_ws_parity/011_protocol_build.md rename to devlog/_fin/260905_http_upstream_ws_parity/011_protocol_build.md diff --git a/devlog/_plan/260905_http_upstream_ws_parity/012_protocol_outcome.md b/devlog/_fin/260905_http_upstream_ws_parity/012_protocol_outcome.md similarity index 100% rename from devlog/_plan/260905_http_upstream_ws_parity/012_protocol_outcome.md rename to devlog/_fin/260905_http_upstream_ws_parity/012_protocol_outcome.md diff --git a/devlog/_plan/260905_http_upstream_ws_parity/020_lifecycle.md b/devlog/_fin/260905_http_upstream_ws_parity/020_lifecycle.md similarity index 100% rename from devlog/_plan/260905_http_upstream_ws_parity/020_lifecycle.md rename to devlog/_fin/260905_http_upstream_ws_parity/020_lifecycle.md diff --git a/devlog/_plan/260905_main_quota_guard/000_plan.md b/devlog/_fin/260905_main_quota_guard/000_plan.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/000_plan.md rename to devlog/_fin/260905_main_quota_guard/000_plan.md diff --git a/devlog/_plan/260905_main_quota_guard/001_source_findings.md b/devlog/_fin/260905_main_quota_guard/001_source_findings.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/001_source_findings.md rename to devlog/_fin/260905_main_quota_guard/001_source_findings.md diff --git a/devlog/_plan/260905_main_quota_guard/002_audit_synthesis.md b/devlog/_fin/260905_main_quota_guard/002_audit_synthesis.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/002_audit_synthesis.md rename to devlog/_fin/260905_main_quota_guard/002_audit_synthesis.md diff --git a/devlog/_plan/260905_main_quota_guard/010_policy.md b/devlog/_fin/260905_main_quota_guard/010_policy.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/010_policy.md rename to devlog/_fin/260905_main_quota_guard/010_policy.md diff --git a/devlog/_plan/260905_main_quota_guard/011_policy_dispatch_contract.md b/devlog/_fin/260905_main_quota_guard/011_policy_dispatch_contract.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/011_policy_dispatch_contract.md rename to devlog/_fin/260905_main_quota_guard/011_policy_dispatch_contract.md diff --git a/devlog/_plan/260905_main_quota_guard/012_implementation_review.md b/devlog/_fin/260905_main_quota_guard/012_implementation_review.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/012_implementation_review.md rename to devlog/_fin/260905_main_quota_guard/012_implementation_review.md diff --git a/devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md b/devlog/_fin/260905_main_quota_guard/013_window_priority_steering.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md rename to devlog/_fin/260905_main_quota_guard/013_window_priority_steering.md diff --git a/devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md b/devlog/_fin/260905_main_quota_guard/014_runtime_evidence.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md rename to devlog/_fin/260905_main_quota_guard/014_runtime_evidence.md diff --git a/devlog/_plan/260905_main_quota_guard/015_recovery_review.md b/devlog/_fin/260905_main_quota_guard/015_recovery_review.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/015_recovery_review.md rename to devlog/_fin/260905_main_quota_guard/015_recovery_review.md diff --git a/devlog/_plan/260905_main_quota_guard/016_policy_input_review.md b/devlog/_fin/260905_main_quota_guard/016_policy_input_review.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/016_policy_input_review.md rename to devlog/_fin/260905_main_quota_guard/016_policy_input_review.md diff --git a/devlog/_plan/260905_main_quota_guard/017_persisted_policy_validation.md b/devlog/_fin/260905_main_quota_guard/017_persisted_policy_validation.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/017_persisted_policy_validation.md rename to devlog/_fin/260905_main_quota_guard/017_persisted_policy_validation.md diff --git a/devlog/_plan/260905_main_quota_guard/018_dev_integration.md b/devlog/_fin/260905_main_quota_guard/018_dev_integration.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/018_dev_integration.md rename to devlog/_fin/260905_main_quota_guard/018_dev_integration.md diff --git a/devlog/_plan/260905_main_quota_guard/019_quota_provenance_integration.md b/devlog/_fin/260905_main_quota_guard/019_quota_provenance_integration.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/019_quota_provenance_integration.md rename to devlog/_fin/260905_main_quota_guard/019_quota_provenance_integration.md diff --git a/devlog/_plan/260905_main_quota_guard/020_settings.md b/devlog/_fin/260905_main_quota_guard/020_settings.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/020_settings.md rename to devlog/_fin/260905_main_quota_guard/020_settings.md diff --git a/devlog/_plan/260905_main_quota_guard/021_settings_dispatch.md b/devlog/_fin/260905_main_quota_guard/021_settings_dispatch.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/021_settings_dispatch.md rename to devlog/_fin/260905_main_quota_guard/021_settings_dispatch.md diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/en-confirm-desktop.jpg b/devlog/_fin/260905_main_quota_guard/022_ui_evidence/en-confirm-desktop.jpg similarity index 100% rename from devlog/_plan/260905_main_quota_guard/022_ui_evidence/en-confirm-desktop.jpg rename to devlog/_fin/260905_main_quota_guard/022_ui_evidence/en-confirm-desktop.jpg diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/en-confirm-mobile.jpg b/devlog/_fin/260905_main_quota_guard/022_ui_evidence/en-confirm-mobile.jpg similarity index 100% rename from devlog/_plan/260905_main_quota_guard/022_ui_evidence/en-confirm-mobile.jpg rename to devlog/_fin/260905_main_quota_guard/022_ui_evidence/en-confirm-mobile.jpg diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-blocked-desktop.jpg b/devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-blocked-desktop.jpg similarity index 100% rename from devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-blocked-desktop.jpg rename to devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-blocked-desktop.jpg diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-desktop.jpg b/devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-confirm-desktop.jpg similarity index 100% rename from devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-desktop.jpg rename to devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-confirm-desktop.jpg diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-mobile.jpg b/devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-confirm-mobile.jpg similarity index 100% rename from devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-mobile.jpg rename to devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-confirm-mobile.jpg diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-tablet.jpg b/devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-confirm-tablet.jpg similarity index 100% rename from devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-confirm-tablet.jpg rename to devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-confirm-tablet.jpg diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-desktop.jpg b/devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-desktop.jpg similarity index 100% rename from devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-desktop.jpg rename to devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-desktop.jpg diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-mobile.jpg b/devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-mobile.jpg similarity index 100% rename from devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-mobile.jpg rename to devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-main-card-integrated-mobile.jpg diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-setting-desktop.jpg b/devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-setting-desktop.jpg similarity index 100% rename from devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-setting-desktop.jpg rename to devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-setting-desktop.jpg diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-setting-integrated-desktop.jpg b/devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-setting-integrated-desktop.jpg similarity index 100% rename from devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-setting-integrated-desktop.jpg rename to devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-setting-integrated-desktop.jpg diff --git a/devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-zero-unlocked-desktop.jpg b/devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-zero-unlocked-desktop.jpg similarity index 100% rename from devlog/_plan/260905_main_quota_guard/022_ui_evidence/ko-zero-unlocked-desktop.jpg rename to devlog/_fin/260905_main_quota_guard/022_ui_evidence/ko-zero-unlocked-desktop.jpg diff --git a/devlog/_plan/260905_main_quota_guard/023_ui_review.md b/devlog/_fin/260905_main_quota_guard/023_ui_review.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/023_ui_review.md rename to devlog/_fin/260905_main_quota_guard/023_ui_review.md diff --git a/devlog/_plan/260905_main_quota_guard/024_ui_verification.md b/devlog/_fin/260905_main_quota_guard/024_ui_verification.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/024_ui_verification.md rename to devlog/_fin/260905_main_quota_guard/024_ui_verification.md diff --git a/devlog/_plan/260905_main_quota_guard/025_focus_review.md b/devlog/_fin/260905_main_quota_guard/025_focus_review.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/025_focus_review.md rename to devlog/_fin/260905_main_quota_guard/025_focus_review.md diff --git a/devlog/_plan/260905_main_quota_guard/026_ui_dev_integration.md b/devlog/_fin/260905_main_quota_guard/026_ui_dev_integration.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/026_ui_dev_integration.md rename to devlog/_fin/260905_main_quota_guard/026_ui_dev_integration.md diff --git a/devlog/_plan/260905_main_quota_guard/030_reserve_compatibility.md b/devlog/_fin/260905_main_quota_guard/030_reserve_compatibility.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/030_reserve_compatibility.md rename to devlog/_fin/260905_main_quota_guard/030_reserve_compatibility.md diff --git a/devlog/_plan/260905_main_quota_guard/031_reserve_dispatch_contract.md b/devlog/_fin/260905_main_quota_guard/031_reserve_dispatch_contract.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/031_reserve_dispatch_contract.md rename to devlog/_fin/260905_main_quota_guard/031_reserve_dispatch_contract.md diff --git a/devlog/_plan/260905_main_quota_guard/032_reserve_audit.md b/devlog/_fin/260905_main_quota_guard/032_reserve_audit.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/032_reserve_audit.md rename to devlog/_fin/260905_main_quota_guard/032_reserve_audit.md diff --git a/devlog/_plan/260905_main_quota_guard/033_parent_review_amendment.md b/devlog/_fin/260905_main_quota_guard/033_parent_review_amendment.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/033_parent_review_amendment.md rename to devlog/_fin/260905_main_quota_guard/033_parent_review_amendment.md diff --git a/devlog/_plan/260905_main_quota_guard/034_recovery_evidence_validation.md b/devlog/_fin/260905_main_quota_guard/034_recovery_evidence_validation.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/034_recovery_evidence_validation.md rename to devlog/_fin/260905_main_quota_guard/034_recovery_evidence_validation.md diff --git a/devlog/_plan/260905_main_quota_guard/039_reserve_verification.md b/devlog/_fin/260905_main_quota_guard/039_reserve_verification.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/039_reserve_verification.md rename to devlog/_fin/260905_main_quota_guard/039_reserve_verification.md diff --git a/devlog/_plan/260905_main_quota_guard/040_delivery.md b/devlog/_fin/260905_main_quota_guard/040_delivery.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/040_delivery.md rename to devlog/_fin/260905_main_quota_guard/040_delivery.md diff --git a/devlog/_plan/260905_main_quota_guard/041_stack_ci_repairs.md b/devlog/_fin/260905_main_quota_guard/041_stack_ci_repairs.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/041_stack_ci_repairs.md rename to devlog/_fin/260905_main_quota_guard/041_stack_ci_repairs.md diff --git a/devlog/_plan/260905_main_quota_guard/044_ingress_verification.md b/devlog/_fin/260905_main_quota_guard/044_ingress_verification.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/044_ingress_verification.md rename to devlog/_fin/260905_main_quota_guard/044_ingress_verification.md diff --git a/devlog/_plan/260905_main_quota_guard/047_claude_policy_verification.md b/devlog/_fin/260905_main_quota_guard/047_claude_policy_verification.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/047_claude_policy_verification.md rename to devlog/_fin/260905_main_quota_guard/047_claude_policy_verification.md diff --git a/devlog/_plan/260905_main_quota_guard/049_delivery_dispatch.md b/devlog/_fin/260905_main_quota_guard/049_delivery_dispatch.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/049_delivery_dispatch.md rename to devlog/_fin/260905_main_quota_guard/049_delivery_dispatch.md diff --git a/devlog/_plan/260905_main_quota_guard/050_ci_contract_repairs.md b/devlog/_fin/260905_main_quota_guard/050_ci_contract_repairs.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/050_ci_contract_repairs.md rename to devlog/_fin/260905_main_quota_guard/050_ci_contract_repairs.md diff --git a/devlog/_plan/260905_main_quota_guard/052_loopback_target_parity.md b/devlog/_fin/260905_main_quota_guard/052_loopback_target_parity.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/052_loopback_target_parity.md rename to devlog/_fin/260905_main_quota_guard/052_loopback_target_parity.md diff --git a/devlog/_plan/260905_main_quota_guard/059_runtime_landing.md b/devlog/_fin/260905_main_quota_guard/059_runtime_landing.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/059_runtime_landing.md rename to devlog/_fin/260905_main_quota_guard/059_runtime_landing.md diff --git a/devlog/_plan/260905_main_quota_guard/063_ui_landing.md b/devlog/_fin/260905_main_quota_guard/063_ui_landing.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/063_ui_landing.md rename to devlog/_fin/260905_main_quota_guard/063_ui_landing.md diff --git a/devlog/_plan/260905_main_quota_guard/064_final_rebase.md b/devlog/_fin/260905_main_quota_guard/064_final_rebase.md similarity index 100% rename from devlog/_plan/260905_main_quota_guard/064_final_rebase.md rename to devlog/_fin/260905_main_quota_guard/064_final_rebase.md diff --git a/devlog/_plan/260905_open_work_closeout/000_plan.md b/devlog/_fin/260905_open_work_closeout/000_plan.md similarity index 98% rename from devlog/_plan/260905_open_work_closeout/000_plan.md rename to devlog/_fin/260905_open_work_closeout/000_plan.md index 74de7e7384d..82444e9e93f 100644 --- a/devlog/_plan/260905_open_work_closeout/000_plan.md +++ b/devlog/_fin/260905_open_work_closeout/000_plan.md @@ -1,6 +1,6 @@ # 000 — Plan and live manifest -Unit: `devlog/_plan/260905_open_work_closeout`. Session `01a06e47-8897-70a0-b669-cf6c5b77d4c3`. +Unit: `devlog/_fin/260905_open_work_closeout`. Session `01a06e47-8897-70a0-b669-cf6c5b77d4c3`. Snapshot: 2026-09-04T21:19:34Z (fetch), `origin/dev` = `0f27bbeb3` (`test(hygiene): fail on duplicate test basenames, and close out the 429 unit (#3527)`). Research worktree: `/private/tmp/ocx-closeout.xomWAA/wt` (detached). diff --git a/devlog/_plan/260905_open_work_closeout/001_lane_bug_prs_a.md b/devlog/_fin/260905_open_work_closeout/001_lane_bug_prs_a.md similarity index 99% rename from devlog/_plan/260905_open_work_closeout/001_lane_bug_prs_a.md rename to devlog/_fin/260905_open_work_closeout/001_lane_bug_prs_a.md index 3d440e58d0f..a1cf2d3d14b 100644 --- a/devlog/_plan/260905_open_work_closeout/001_lane_bug_prs_a.md +++ b/devlog/_fin/260905_open_work_closeout/001_lane_bug_prs_a.md @@ -388,7 +388,7 @@ halves so the security-reviewed surface is isolated. - Every `bun test` invocation named a specific file; no repository-wide suite was run. - Patches were applied to the worktree only to establish red/green evidence and were reverted; - final `git status --porcelain` shows only `?? devlog/_plan/260905_open_work_closeout/`. + final `git status --porcelain` shows only `?? devlog/_fin/260905_open_work_closeout/`. - `git fetch origin pull/3502/head:tmp-pr-3502` was fetch-only; no checkout of `dev` occurred. - `git merge-tree` could not run (sandbox denies its temp-file creation), so 3502's conflict set was established with per-file `git apply --check` plus `git log origin/dev ^tmp-pr-3502`. diff --git a/devlog/_plan/260905_open_work_closeout/002_lane_bug_prs_b.md b/devlog/_fin/260905_open_work_closeout/002_lane_bug_prs_b.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/002_lane_bug_prs_b.md rename to devlog/_fin/260905_open_work_closeout/002_lane_bug_prs_b.md diff --git a/devlog/_plan/260905_open_work_closeout/003_lane_v2_and_quota.md b/devlog/_fin/260905_open_work_closeout/003_lane_v2_and_quota.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/003_lane_v2_and_quota.md rename to devlog/_fin/260905_open_work_closeout/003_lane_v2_and_quota.md diff --git a/devlog/_plan/260905_open_work_closeout/004_lane_else_prs.md b/devlog/_fin/260905_open_work_closeout/004_lane_else_prs.md similarity index 99% rename from devlog/_plan/260905_open_work_closeout/004_lane_else_prs.md rename to devlog/_fin/260905_open_work_closeout/004_lane_else_prs.md index 6ce37202c51..579faf47a8f 100644 --- a/devlog/_plan/260905_open_work_closeout/004_lane_else_prs.md +++ b/devlog/_fin/260905_open_work_closeout/004_lane_else_prs.md @@ -5,7 +5,7 @@ READ-ONLY adversarial triage. Worktree `/private/tmp/ocx-closeout.xomWAA/wt`, de untouched during the review: `src/oauth/index.ts`, `src/providers/model-rename-startup.ts`, `tests/oauth/oauth-provider-reconcile.test.ts`, `tests/providers/model-rename-migration.test.ts`). At the final index re-read these had been reverted by the parent; the only remaining working-tree -entry is this untracked `devlog/_plan/260905_open_work_closeout/` directory. No verdict depends on +entry is this untracked `devlog/_fin/260905_open_work_closeout/` directory. No verdict depends on those files. All PR-head verification ran against `git archive` exports under diff --git a/devlog/_plan/260905_open_work_closeout/005_lane_bug_issues.md b/devlog/_fin/260905_open_work_closeout/005_lane_bug_issues.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/005_lane_bug_issues.md rename to devlog/_fin/260905_open_work_closeout/005_lane_bug_issues.md diff --git a/devlog/_plan/260905_open_work_closeout/006_dispositions.md b/devlog/_fin/260905_open_work_closeout/006_dispositions.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/006_dispositions.md rename to devlog/_fin/260905_open_work_closeout/006_dispositions.md diff --git a/devlog/_plan/260905_open_work_closeout/007_audit_wp0.md b/devlog/_fin/260905_open_work_closeout/007_audit_wp0.md similarity index 99% rename from devlog/_plan/260905_open_work_closeout/007_audit_wp0.md rename to devlog/_fin/260905_open_work_closeout/007_audit_wp0.md index fc727b87d39..954de845133 100644 --- a/devlog/_plan/260905_open_work_closeout/007_audit_wp0.md +++ b/devlog/_fin/260905_open_work_closeout/007_audit_wp0.md @@ -13,7 +13,7 @@ all evidence is repository- and `gh`-local, which is Tier-2-equivalent primary s | Worktree `HEAD` | `0f27bbeb3ce6a92077652695e161d49b88eedc7a` (detached, unchanged; no checkout performed) | | `origin/dev` at audit start | `79e03643d7cfa2b6c3c4eb8afd6179a140b197a3` | | `origin/dev` at pre-verdict re-read | `79e03643d7cfa2b6c3c4eb8afd6179a140b197a3` (unchanged) | -| Index at pre-verdict re-read | nothing staged; only `?? devlog/_plan/260905_open_work_closeout/` untracked | +| Index at pre-verdict re-read | nothing staged; only `?? devlog/_fin/260905_open_work_closeout/` untracked | | Audit target | `000`, `006`, `010`, `020`, `030`, `040`, `050`, `060` (all read in full); `001`-`005` as supporting evidence | `git merge-base --is-ancestor HEAD origin/dev` -> exit 0. The worktree is **3 commits behind** diff --git a/devlog/_plan/260905_open_work_closeout/008_audit_synthesis.md b/devlog/_fin/260905_open_work_closeout/008_audit_synthesis.md similarity index 96% rename from devlog/_plan/260905_open_work_closeout/008_audit_synthesis.md rename to devlog/_fin/260905_open_work_closeout/008_audit_synthesis.md index dccbbfde7b2..631fd9171af 100644 --- a/devlog/_plan/260905_open_work_closeout/008_audit_synthesis.md +++ b/devlog/_fin/260905_open_work_closeout/008_audit_synthesis.md @@ -101,4 +101,4 @@ closeout commit; then the unit moves to `devlog/_fin/`. Main-agent judgment: **near-pass** → exit A>B for wp0 (docs-only). Residuals: none blocking. -DOCEOF; rg -n 'Author \| Head merged' /private/tmp/ocx-closeout.xomWAA/wt/devlog/_plan/260905_open_work_closeout/010_wp1_stack_a_land_as_is.md | head -2; tail -3 /private/tmp/ocx-closeout.xomWAA/wt/devlog/_plan/260905_open_work_closeout/060_ledger.md \ No newline at end of file +DOCEOF; rg -n 'Author \| Head merged' /private/tmp/ocx-closeout.xomWAA/wt/devlog/_fin/260905_open_work_closeout/010_wp1_stack_a_land_as_is.md | head -2; tail -3 /private/tmp/ocx-closeout.xomWAA/wt/devlog/_fin/260905_open_work_closeout/060_ledger.md \ No newline at end of file diff --git a/devlog/_plan/260905_open_work_closeout/010_wp1_stack_a_land_as_is.md b/devlog/_fin/260905_open_work_closeout/010_wp1_stack_a_land_as_is.md similarity index 99% rename from devlog/_plan/260905_open_work_closeout/010_wp1_stack_a_land_as_is.md rename to devlog/_fin/260905_open_work_closeout/010_wp1_stack_a_land_as_is.md index 5fdcfc31a10..49fc5e3bd06 100644 --- a/devlog/_plan/260905_open_work_closeout/010_wp1_stack_a_land_as_is.md +++ b/devlog/_fin/260905_open_work_closeout/010_wp1_stack_a_land_as_is.md @@ -2,7 +2,7 @@ # 010 — wp1 / Stack A: the LAND_AS_IS merge train -Work-phase: **wp1**. Unit `devlog/_plan/260905_open_work_closeout`. +Work-phase: **wp1**. Unit `devlog/_fin/260905_open_work_closeout`. Scope: **#3515, #3529, #3525, #3490, #3484, #3480, #3323** — seven independent open pull requests whose code is correct as written. This is a **merge-train recipe**, not a rebase or reimplementation plan: no `src/`, `tests/`, or `gui/` file is authored in this diff --git a/devlog/_plan/260905_open_work_closeout/011_wp1_execution.md b/devlog/_fin/260905_open_work_closeout/011_wp1_execution.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/011_wp1_execution.md rename to devlog/_fin/260905_open_work_closeout/011_wp1_execution.md diff --git a/devlog/_plan/260905_open_work_closeout/012_wp1_delivery_record.md b/devlog/_fin/260905_open_work_closeout/012_wp1_delivery_record.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/012_wp1_delivery_record.md rename to devlog/_fin/260905_open_work_closeout/012_wp1_delivery_record.md diff --git a/devlog/_plan/260905_open_work_closeout/020_wp2_stack_b_bug_carry.md b/devlog/_fin/260905_open_work_closeout/020_wp2_stack_b_bug_carry.md similarity index 99% rename from devlog/_plan/260905_open_work_closeout/020_wp2_stack_b_bug_carry.md rename to devlog/_fin/260905_open_work_closeout/020_wp2_stack_b_bug_carry.md index cf8636a211d..c9aaa7a754f 100644 --- a/devlog/_plan/260905_open_work_closeout/020_wp2_stack_b_bug_carry.md +++ b/devlog/_fin/260905_open_work_closeout/020_wp2_stack_b_bug_carry.md @@ -2,7 +2,7 @@ # 020 — wp2 / Stack B: bug PRs needing carry or reimplementation -Unit: `devlog/_plan/260905_open_work_closeout`. Work-phase: **wp2**. +Unit: `devlog/_fin/260905_open_work_closeout`. Work-phase: **wp2**. Sources: `001_lane_bug_prs_a.md` (#3502, #3519, #3524) and `002_lane_bug_prs_b.md` (#3489, #3469, #3407, #3348, #3388), both read in full. diff --git a/devlog/_plan/260905_open_work_closeout/021_wp2_scope_amendment.md b/devlog/_fin/260905_open_work_closeout/021_wp2_scope_amendment.md similarity index 95% rename from devlog/_plan/260905_open_work_closeout/021_wp2_scope_amendment.md rename to devlog/_fin/260905_open_work_closeout/021_wp2_scope_amendment.md index d016d5413d7..db17bff50a5 100644 --- a/devlog/_plan/260905_open_work_closeout/021_wp2_scope_amendment.md +++ b/devlog/_fin/260905_open_work_closeout/021_wp2_scope_amendment.md @@ -68,7 +68,7 @@ originals closed at wp6. Trailers use the id-prefixed noreply form (008 Blocker B6's new 400→502 test must assert a status that `errors.ts:452` does not already map (use a non-`server_error` category) so it cannot pass vacuously. -DOCEOF; cp /Users/jun/Developer/new/700_projects/opencodex/devlog/_plan/260905_open_work_closeout/021_wp2_scope_amendment.md /private/tmp/ocx-closeout.xomWAA/wt/devlog/_plan/260905_open_work_closeout/; cp /private/tmp/ocx-closeout.xomWAA/wt/devlog/_plan/260905_open_work_closeout/022_audit_wp2.md /Users/jun/Developer/new/700_projects/opencodex/devlog/_plan/260905_open_work_closeout/ +DOCEOF; cp /Users/jun/Developer/new/700_projects/opencodex/devlog/_fin/260905_open_work_closeout/021_wp2_scope_amendment.md /private/tmp/ocx-closeout.xomWAA/wt/devlog/_fin/260905_open_work_closeout/; cp /private/tmp/ocx-closeout.xomWAA/wt/devlog/_fin/260905_open_work_closeout/022_audit_wp2.md /Users/jun/Developer/new/700_projects/opencodex/devlog/_fin/260905_open_work_closeout/ ## B note — carry-3489 gated on #3551 `git merge-tree --write-tree refs/tmp/pr-3551 refs/tmp/pr-3489` → CONFLICT (`src/lib/provider-outbound.ts`, @@ -115,7 +115,7 @@ touches only `src/cli/claude.ts`, `src/cli/registry.ts`, docs, and its own test; B3's head and on `origin/dev` locally (3× repeat). Classified as a timing flake pending the exact-head rerun; not asserted as flake until the rerun reports. -DOCEOF; cp /private/tmp/ocx-closeout.xomWAA/wt/devlog/_plan/260905_open_work_closeout/023_impl_review_wp2.md /Users/jun/Developer/new/700_projects/opencodex/devlog/_plan/260905_open_work_closeout/ +DOCEOF; cp /private/tmp/ocx-closeout.xomWAA/wt/devlog/_fin/260905_open_work_closeout/023_impl_review_wp2.md /Users/jun/Developer/new/700_projects/opencodex/devlog/_fin/260905_open_work_closeout/ ### Merges and cascade (DEV-STACK-02) B1 #3561 → `71cfc8de6`, B2 #3562 → `24cc558d5` (admin squash, bypass comments, ancestry exit 0). diff --git a/devlog/_plan/260905_open_work_closeout/024_wp2_delivery_record.md b/devlog/_fin/260905_open_work_closeout/024_wp2_delivery_record.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/024_wp2_delivery_record.md rename to devlog/_fin/260905_open_work_closeout/024_wp2_delivery_record.md diff --git a/devlog/_plan/260905_open_work_closeout/030_wp3_stack_c_v2_passthrough.md b/devlog/_fin/260905_open_work_closeout/030_wp3_stack_c_v2_passthrough.md similarity index 99% rename from devlog/_plan/260905_open_work_closeout/030_wp3_stack_c_v2_passthrough.md rename to devlog/_fin/260905_open_work_closeout/030_wp3_stack_c_v2_passthrough.md index 2559eb94e58..8660a4a6875 100644 --- a/devlog/_plan/260905_open_work_closeout/030_wp3_stack_c_v2_passthrough.md +++ b/devlog/_fin/260905_open_work_closeout/030_wp3_stack_c_v2_passthrough.md @@ -1,6 +1,6 @@ # 030 — wp3 / Stack C: #3444 direct encrypted V2 task passthrough -Unit: `devlog/_plan/260905_open_work_closeout`. Work-phase: **wp3**. Lane source: +Unit: `devlog/_fin/260905_open_work_closeout`. Work-phase: **wp3**. Lane source: [`003_lane_v2_and_quota.md`](./003_lane_v2_and_quota.md) (#3444 section). Re-verified against `origin/dev` = `6580694c7911cfbf78da63b6258ec1c70bd8a0e3` diff --git a/devlog/_plan/260905_open_work_closeout/031_wp3_reverify.md b/devlog/_fin/260905_open_work_closeout/031_wp3_reverify.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/031_wp3_reverify.md rename to devlog/_fin/260905_open_work_closeout/031_wp3_reverify.md diff --git a/devlog/_plan/260905_open_work_closeout/032_wp3_delivery_record.md b/devlog/_fin/260905_open_work_closeout/032_wp3_delivery_record.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/032_wp3_delivery_record.md rename to devlog/_fin/260905_open_work_closeout/032_wp3_delivery_record.md diff --git a/devlog/_plan/260905_open_work_closeout/040_wp4_stack_d_usage_quota.md b/devlog/_fin/260905_open_work_closeout/040_wp4_stack_d_usage_quota.md similarity index 99% rename from devlog/_plan/260905_open_work_closeout/040_wp4_stack_d_usage_quota.md rename to devlog/_fin/260905_open_work_closeout/040_wp4_stack_d_usage_quota.md index 3037164dc47..d7b9432af7f 100644 --- a/devlog/_plan/260905_open_work_closeout/040_wp4_stack_d_usage_quota.md +++ b/devlog/_fin/260905_open_work_closeout/040_wp4_stack_d_usage_quota.md @@ -4,7 +4,7 @@ Diff-level implementation plan for one PABCD work-phase. Written to be executabl implementer with no other context: every change site is quoted from the tree, every conflict has a mechanical-or-semantic verdict, and every verifier was run before this doc was written. -Unit: `devlog/_plan/260905_open_work_closeout`. Lane research: `003_lane_v2_and_quota.md`. +Unit: `devlog/_fin/260905_open_work_closeout`. Lane research: `003_lane_v2_and_quota.md`. ## Base state (re-read immediately before writing) diff --git a/devlog/_plan/260905_open_work_closeout/041_wp4_reverify.md b/devlog/_fin/260905_open_work_closeout/041_wp4_reverify.md similarity index 92% rename from devlog/_plan/260905_open_work_closeout/041_wp4_reverify.md rename to devlog/_fin/260905_open_work_closeout/041_wp4_reverify.md index 05eb41d1b09..da82f7ab058 100644 --- a/devlog/_plan/260905_open_work_closeout/041_wp4_reverify.md +++ b/devlog/_fin/260905_open_work_closeout/041_wp4_reverify.md @@ -23,7 +23,7 @@ Trailers: layer 1 `hualiny <82697947+hualiny@users.noreply.github.com>`, layer 3 Stack: layer 1 → dev; layer 2 → layer 1; layer 3 → dev (independent). Layer 1 and 3 PRs open now; layer 2 PR opens when its lane finishes. -DOCEOF; cp /Users/jun/Developer/new/700_projects/opencodex/devlog/_plan/260905_open_work_closeout/041_wp4_reverify.md /private/tmp/ocx-closeout.xomWAA/wt/devlog/_plan/260905_open_work_closeout/ +DOCEOF; cp /Users/jun/Developer/new/700_projects/opencodex/devlog/_fin/260905_open_work_closeout/041_wp4_reverify.md /private/tmp/ocx-closeout.xomWAA/wt/devlog/_fin/260905_open_work_closeout/ ## Audit fold (wp4 A round 1 — claude-opus-5, GO-WITH-FIXES blockers=5; report 042) 1. **Layer 2 test layout (High):** seven of #2783's test basenames resolve to `null` (incl. @@ -48,4 +48,4 @@ the Antigravity row. Fixed in `4a721e459` by injecting the pinned-transport seam `fetchAvailableModels` fallback is what the test exercises, as it did before. Layer 1 final: 156 pass / 0 fail unsandboxed, typecheck 0. -DOCEOF; cp /private/tmp/ocx-closeout.xomWAA/wt/devlog/_plan/260905_open_work_closeout/042_audit_wp4.md /Users/jun/Developer/new/700_projects/opencodex/devlog/_plan/260905_open_work_closeout/ +DOCEOF; cp /private/tmp/ocx-closeout.xomWAA/wt/devlog/_fin/260905_open_work_closeout/042_audit_wp4.md /Users/jun/Developer/new/700_projects/opencodex/devlog/_fin/260905_open_work_closeout/ diff --git a/devlog/_plan/260905_open_work_closeout/044_wp4_delivery_record.md b/devlog/_fin/260905_open_work_closeout/044_wp4_delivery_record.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/044_wp4_delivery_record.md rename to devlog/_fin/260905_open_work_closeout/044_wp4_delivery_record.md diff --git a/devlog/_plan/260905_open_work_closeout/050_wp5_stack_e_else.md b/devlog/_fin/260905_open_work_closeout/050_wp5_stack_e_else.md similarity index 99% rename from devlog/_plan/260905_open_work_closeout/050_wp5_stack_e_else.md rename to devlog/_fin/260905_open_work_closeout/050_wp5_stack_e_else.md index 3a8929d2862..589648d87ef 100644 --- a/devlog/_plan/260905_open_work_closeout/050_wp5_stack_e_else.md +++ b/devlog/_fin/260905_open_work_closeout/050_wp5_stack_e_else.md @@ -2,7 +2,7 @@ # 050 — wp5 / Stack E: remaining ready PRs and implementable bug issues -Unit: `devlog/_plan/260905_open_work_closeout`. Work-phase **wp5**, one PABCD cycle. +Unit: `devlog/_fin/260905_open_work_closeout`. Work-phase **wp5**, one PABCD cycle. Worktree: `/private/tmp/ocx-closeout.xomWAA/wt` (detached). Sources: `004_lane_else_prs.md` (#3530 #3487 #3508 #3383 #3329 #3421 #2716 #2432 #3531 #3528), `005_lane_bug_issues.md` (#3464, #3425). diff --git a/devlog/_plan/260905_open_work_closeout/051_wp5_reverify.md b/devlog/_fin/260905_open_work_closeout/051_wp5_reverify.md similarity index 89% rename from devlog/_plan/260905_open_work_closeout/051_wp5_reverify.md rename to devlog/_fin/260905_open_work_closeout/051_wp5_reverify.md index 189d60ce88c..8ed7f33a297 100644 --- a/devlog/_plan/260905_open_work_closeout/051_wp5_reverify.md +++ b/devlog/_fin/260905_open_work_closeout/051_wp5_reverify.md @@ -25,4 +25,4 @@ benedictusrey888 192305729 (per 007 round 2, #3531's author identity), Veritas-7 Stack shape: E0-E7 share no source file (050 measurement) → seven independent PRs against `dev`, merged in E-order. Verifiers: typecheck + each layer's named files + layout guard. -DOCEOF; cp /Users/jun/Developer/new/700_projects/opencodex/devlog/_plan/260905_open_work_closeout/051_wp5_reverify.md /Users/jun/Developer/new/700_projects/opencodex/devlog/_plan/260905_open_work_closeout/044_wp4_delivery_record.md /private/tmp/ocx-closeout.xomWAA/wt/devlog/_plan/260905_open_work_closeout/ 2>/dev/null \ No newline at end of file +DOCEOF; cp /Users/jun/Developer/new/700_projects/opencodex/devlog/_fin/260905_open_work_closeout/051_wp5_reverify.md /Users/jun/Developer/new/700_projects/opencodex/devlog/_fin/260905_open_work_closeout/044_wp4_delivery_record.md /private/tmp/ocx-closeout.xomWAA/wt/devlog/_fin/260905_open_work_closeout/ 2>/dev/null \ No newline at end of file diff --git a/devlog/_plan/260905_open_work_closeout/052_wp5_delivery.md b/devlog/_fin/260905_open_work_closeout/052_wp5_delivery.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/052_wp5_delivery.md rename to devlog/_fin/260905_open_work_closeout/052_wp5_delivery.md diff --git a/devlog/_plan/260905_open_work_closeout/053_residual_integration.md b/devlog/_fin/260905_open_work_closeout/053_residual_integration.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/053_residual_integration.md rename to devlog/_fin/260905_open_work_closeout/053_residual_integration.md diff --git a/devlog/_plan/260905_open_work_closeout/054_final_ci_pin.md b/devlog/_fin/260905_open_work_closeout/054_final_ci_pin.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/054_final_ci_pin.md rename to devlog/_fin/260905_open_work_closeout/054_final_ci_pin.md diff --git a/devlog/_plan/260905_open_work_closeout/055_linux_ci_repair.md b/devlog/_fin/260905_open_work_closeout/055_linux_ci_repair.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/055_linux_ci_repair.md rename to devlog/_fin/260905_open_work_closeout/055_linux_ci_repair.md diff --git a/devlog/_plan/260905_open_work_closeout/056_second_ci_head.md b/devlog/_fin/260905_open_work_closeout/056_second_ci_head.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/056_second_ci_head.md rename to devlog/_fin/260905_open_work_closeout/056_second_ci_head.md diff --git a/devlog/_plan/260905_open_work_closeout/057_coordinated_final_ci.md b/devlog/_fin/260905_open_work_closeout/057_coordinated_final_ci.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/057_coordinated_final_ci.md rename to devlog/_fin/260905_open_work_closeout/057_coordinated_final_ci.md diff --git a/devlog/_plan/260905_open_work_closeout/058_final_execution_result.md b/devlog/_fin/260905_open_work_closeout/058_final_execution_result.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/058_final_execution_result.md rename to devlog/_fin/260905_open_work_closeout/058_final_execution_result.md diff --git a/devlog/_plan/260905_open_work_closeout/059_owner_directed_stop.md b/devlog/_fin/260905_open_work_closeout/059_owner_directed_stop.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/059_owner_directed_stop.md rename to devlog/_fin/260905_open_work_closeout/059_owner_directed_stop.md diff --git a/devlog/_plan/260905_open_work_closeout/060_ledger.md b/devlog/_fin/260905_open_work_closeout/060_ledger.md similarity index 100% rename from devlog/_plan/260905_open_work_closeout/060_ledger.md rename to devlog/_fin/260905_open_work_closeout/060_ledger.md diff --git a/devlog/_plan/260905_provider_registration_selection/000_plan.md b/devlog/_fin/260905_provider_registration_selection/000_plan.md similarity index 100% rename from devlog/_plan/260905_provider_registration_selection/000_plan.md rename to devlog/_fin/260905_provider_registration_selection/000_plan.md diff --git a/devlog/_plan/260905_provider_registration_selection/001_roadmap_audit.md b/devlog/_fin/260905_provider_registration_selection/001_roadmap_audit.md similarity index 100% rename from devlog/_plan/260905_provider_registration_selection/001_roadmap_audit.md rename to devlog/_fin/260905_provider_registration_selection/001_roadmap_audit.md diff --git a/devlog/_plan/260905_provider_registration_selection/010_initial_selection.md b/devlog/_fin/260905_provider_registration_selection/010_initial_selection.md similarity index 100% rename from devlog/_plan/260905_provider_registration_selection/010_initial_selection.md rename to devlog/_fin/260905_provider_registration_selection/010_initial_selection.md diff --git a/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md b/devlog/_fin/260905_provider_registration_selection/020_registration_guidance.md similarity index 100% rename from devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md rename to devlog/_fin/260905_provider_registration_selection/020_registration_guidance.md diff --git a/devlog/_plan/260905_provider_registration_selection/021_registration_notice.png b/devlog/_fin/260905_provider_registration_selection/021_registration_notice.png similarity index 100% rename from devlog/_plan/260905_provider_registration_selection/021_registration_notice.png rename to devlog/_fin/260905_provider_registration_selection/021_registration_notice.png diff --git a/devlog/_plan/260905_provider_registration_selection/022_models_all_off.png b/devlog/_fin/260905_provider_registration_selection/022_models_all_off.png similarity index 100% rename from devlog/_plan/260905_provider_registration_selection/022_models_all_off.png rename to devlog/_fin/260905_provider_registration_selection/022_models_all_off.png diff --git a/devlog/_plan/260905_provider_registration_selection/023_onboarding_verification.md b/devlog/_fin/260905_provider_registration_selection/023_onboarding_verification.md similarity index 100% rename from devlog/_plan/260905_provider_registration_selection/023_onboarding_verification.md rename to devlog/_fin/260905_provider_registration_selection/023_onboarding_verification.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/000_plan.md b/devlog/_fin/260905_provider_usage_quota_parity/000_plan.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/000_plan.md rename to devlog/_fin/260905_provider_usage_quota_parity/000_plan.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/001_attribution_evidence.md b/devlog/_fin/260905_provider_usage_quota_parity/001_attribution_evidence.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/001_attribution_evidence.md rename to devlog/_fin/260905_provider_usage_quota_parity/001_attribution_evidence.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/002_audit_synthesis.md b/devlog/_fin/260905_provider_usage_quota_parity/002_audit_synthesis.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/002_audit_synthesis.md rename to devlog/_fin/260905_provider_usage_quota_parity/002_audit_synthesis.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/010_attribution.md b/devlog/_fin/260905_provider_usage_quota_parity/010_attribution.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/010_attribution.md rename to devlog/_fin/260905_provider_usage_quota_parity/010_attribution.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/011_attribution_build.md b/devlog/_fin/260905_provider_usage_quota_parity/011_attribution_build.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/011_attribution_build.md rename to devlog/_fin/260905_provider_usage_quota_parity/011_attribution_build.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/012_premerge_review.md b/devlog/_fin/260905_provider_usage_quota_parity/012_premerge_review.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/012_premerge_review.md rename to devlog/_fin/260905_provider_usage_quota_parity/012_premerge_review.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/013_ci_test_boundaries.md b/devlog/_fin/260905_provider_usage_quota_parity/013_ci_test_boundaries.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/013_ci_test_boundaries.md rename to devlog/_fin/260905_provider_usage_quota_parity/013_ci_test_boundaries.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/014_recovery_fixture_isolation.md b/devlog/_fin/260905_provider_usage_quota_parity/014_recovery_fixture_isolation.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/014_recovery_fixture_isolation.md rename to devlog/_fin/260905_provider_usage_quota_parity/014_recovery_fixture_isolation.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/020_account_quota_api.md b/devlog/_fin/260905_provider_usage_quota_parity/020_account_quota_api.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/020_account_quota_api.md rename to devlog/_fin/260905_provider_usage_quota_parity/020_account_quota_api.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/021_api_build.md b/devlog/_fin/260905_provider_usage_quota_parity/021_api_build.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/021_api_build.md rename to devlog/_fin/260905_provider_usage_quota_parity/021_api_build.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/030_quota_views.md b/devlog/_fin/260905_provider_usage_quota_parity/030_quota_views.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/030_quota_views.md rename to devlog/_fin/260905_provider_usage_quota_parity/030_quota_views.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/031_ui_build.md b/devlog/_fin/260905_provider_usage_quota_parity/031_ui_build.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/031_ui_build.md rename to devlog/_fin/260905_provider_usage_quota_parity/031_ui_build.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md b/devlog/_fin/260905_provider_usage_quota_parity/040_stack_landing.md similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/040_stack_landing.md rename to devlog/_fin/260905_provider_usage_quota_parity/040_stack_landing.md diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/011_usage_attribution.png b/devlog/_fin/260905_provider_usage_quota_parity/assets/011_usage_attribution.png similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/assets/011_usage_attribution.png rename to devlog/_fin/260905_provider_usage_quota_parity/assets/011_usage_attribution.png diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/031_current_overview.png b/devlog/_fin/260905_provider_usage_quota_parity/assets/031_current_overview.png similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/assets/031_current_overview.png rename to devlog/_fin/260905_provider_usage_quota_parity/assets/031_current_overview.png diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/032_current_usage.png b/devlog/_fin/260905_provider_usage_quota_parity/assets/032_current_usage.png similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/assets/032_current_usage.png rename to devlog/_fin/260905_provider_usage_quota_parity/assets/032_current_usage.png diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/033_oauth_accounts.png b/devlog/_fin/260905_provider_usage_quota_parity/assets/033_oauth_accounts.png similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/assets/033_oauth_accounts.png rename to devlog/_fin/260905_provider_usage_quota_parity/assets/033_oauth_accounts.png diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/034_api_key_balances.png b/devlog/_fin/260905_provider_usage_quota_parity/assets/034_api_key_balances.png similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/assets/034_api_key_balances.png rename to devlog/_fin/260905_provider_usage_quota_parity/assets/034_api_key_balances.png diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/035_passive_current_unobserved.png b/devlog/_fin/260905_provider_usage_quota_parity/assets/035_passive_current_unobserved.png similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/assets/035_passive_current_unobserved.png rename to devlog/_fin/260905_provider_usage_quota_parity/assets/035_passive_current_unobserved.png diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/036_pool_current.png b/devlog/_fin/260905_provider_usage_quota_parity/assets/036_pool_current.png similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/assets/036_pool_current.png rename to devlog/_fin/260905_provider_usage_quota_parity/assets/036_pool_current.png diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/037_tablet_usage.png b/devlog/_fin/260905_provider_usage_quota_parity/assets/037_tablet_usage.png similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/assets/037_tablet_usage.png rename to devlog/_fin/260905_provider_usage_quota_parity/assets/037_tablet_usage.png diff --git a/devlog/_plan/260905_provider_usage_quota_parity/assets/039_mobile_current.png b/devlog/_fin/260905_provider_usage_quota_parity/assets/039_mobile_current.png similarity index 100% rename from devlog/_plan/260905_provider_usage_quota_parity/assets/039_mobile_current.png rename to devlog/_fin/260905_provider_usage_quota_parity/assets/039_mobile_current.png diff --git a/devlog/_fin/260905_test_modularization_and_windows/001_test_inventory.md b/devlog/_fin/260905_test_modularization_and_windows/001_test_inventory.md index 44defb0acd3..44e1e8d30a3 100644 --- a/devlog/_fin/260905_test_modularization_and_windows/001_test_inventory.md +++ b/devlog/_fin/260905_test_modularization_and_windows/001_test_inventory.md @@ -283,6 +283,12 @@ Sum of the table: **1061**. Zero leftover. ### 2.D Full membership (every `*.test.ts`) +#### `tests/advisor/` (8) + +`advisor-context.test.ts`, `advisor-consult.test.ts`, `advisor-guard.test.ts`, `advisor-internal-authority.test.ts`, `advisor-plan.test.ts`, `advisor-responses-wiring.test.ts`, `advisor-settings.test.ts`, `advisor-state.test.ts` + +Additional server-domain coverage: `tests/server/advisor-routes.test.ts`. + #### `tests/codex-integration/` (175) `active-registry-admission.test.ts`, `app-owned-memory.test.ts`, `bearer-admission-routed-provider.test.ts`, `catalog-cursor-search.test.ts`, `catalog-input-modality-enum.test.ts`, `catalog-llamacpp-capabilities.test.ts`, `catalog-oauth-observation.test.ts`, `catalog-retain-models.test.ts`, `catalog-verbosity-default.test.ts`, `catalog-vision-sidecar-modalities.test.ts`, `codex-account-delete-atomicity.test.ts`, `codex-account-label.test.ts`, `codex-account-namespaces.test.ts`, `codex-account-store.test.ts`, `codex-admission-primitives.test.ts`, `codex-admission.test.ts`, `codex-affinity-debug.test.ts`, `codex-app-server-path-spaces.test.ts`, `codex-app-server-processes.test.ts`, `codex-app-server-restart-service.test.ts`, `codex-auth-api.test.ts`, `codex-auth-collision.test.ts`, `codex-auth-context.test.ts`, `codex-catalog-admission.test.ts`, `codex-catalog-golden.test.ts`, `codex-catalog-model-picker-order.test.ts`, `codex-catalog-refresh-status.test.ts`, `codex-catalog-restore.test.ts`, `codex-catalog-sync-hardening.test.ts`, `codex-catalog-write-serialization.test.ts`, `codex-catalog-writer.test.ts`, `codex-catalog.test.ts`, `codex-cli-install-provenance.test.ts`, `codex-cli-update-launcher-policy.test.ts`, `codex-cli-update-zero-effect.test.ts`, `codex-composed-acceptance.test.ts`, `codex-config-generation.test.ts`, `codex-convergence-account-selectors.test.ts`, `codex-convergence-contract.test.ts`, `codex-cooldown-recovery.test.ts`, `codex-coordinator-doctor.test.ts`, `codex-desired-state.test.ts`, `codex-envkey-admission-substitution.test.ts`, `codex-exec-invocation.test.ts`, `codex-features-cache.test.ts`, `codex-features-residual.test.ts`, `codex-filesystem-evidence.test.ts`, `codex-gather-authority.test.ts`, `codex-history-job.test.ts`, `codex-history-lock.test.ts`, `codex-history-provider.test.ts`, `codex-history-reachability.test.ts`, `codex-history-worker-boundary.test.ts`, `codex-history-worker.test.ts`, `codex-history-writer.test.ts`, `codex-home-wsl.test.ts`, `codex-inject-history-wording.test.ts`, `codex-inject-integration.test.ts`, `codex-inject-write-lock.test.ts`, `codex-inject.test.ts`, `codex-injected-marker.test.ts`, `codex-integration-record.test.ts`, `codex-journal.test.ts`, `codex-log-guard-coderabbit.test.ts`, `codex-log-guard-doctor-coderabbit.test.ts`, `codex-log-guard-doctor-protection.test.ts`, `codex-log-guard-doctor.test.ts`, `codex-log-guard-inspect.test.ts`, `codex-log-guard-lock.test.ts`, `codex-log-guard-maintenance-coderabbit.test.ts`, `codex-log-guard-maintenance.test.ts`, `codex-log-guard-policy.test.ts`, `codex-log-guard-processes.test.ts`, `codex-log-guard-protection.test.ts`, `codex-log-guard-status-zero-write.test.ts`, `codex-main-account-refresh.test.ts`, `codex-main-rotation.test.ts`, `codex-management-convergence.test.ts`, `codex-metadata-integrity.test.ts`, `codex-model-entitlements.test.ts`, `codex-models-cache-invalidate.test.ts`, `codex-native-residue.test.ts`, `codex-plan.test.ts`, `codex-plugins-doctor.test.ts`, `codex-pool-rotation.test.ts`, `codex-prompt-adopt.test.ts`, `codex-prompt-base-variants.test.ts`, `codex-prompt-journal.test.ts`, `codex-prompt-layers-read.test.ts`, `codex-prompt-layers-write.test.ts`, `codex-prompt-layers.test.ts`, `codex-prompt-lock.test.ts`, `codex-prompt-route.test.ts`, `codex-prompt-text-probe.test.ts`, `codex-quota-parser-parity.test.ts`, `codex-quota-prime.test.ts`, `codex-quota-rejection.test.ts`, `codex-refresh.test.ts`, `codex-reset-credit-auto-redeem.test.ts`, `codex-reset-credit-operation-ledger.test.ts`, `codex-reset-credit-recovery.test.ts`, `codex-restart-contract-parity.test.ts`, `codex-restart-route.test.ts`, `codex-restore-app-rewrite.test.ts`, `codex-retained-root-serialization.test.ts`, `codex-routing.test.ts`, `codex-runtime.test.ts`, `codex-service-manager-probe-hardening.test.ts`, `codex-service-manager-probe.test.ts`, `codex-shim-autorestore.test.ts`, `codex-shim-readiness.test.ts`, `codex-shim.test.ts`, `codex-spark-visibility.test.ts`, `codex-sqlite-home.test.ts`, `codex-sync-api.test.ts`, `codex-sync-response.test.ts`, `codex-tool-mode.test.ts`, `codex-transition-state-adoption.test.ts`, `codex-transition-state-first-use-regression.test.ts`, `codex-transition-state-race.test.ts`, `codex-transition-state.test.ts`, `codex-user-identity.test.ts`, `codex-v2-gate.test.ts`, `codex-warmup.test.ts`, `codex-websocket-registry.test.ts`, `codex-write-lock.test.ts`, `combos.test.ts`, `compatibility-manifest.test.ts`, `custom-model-catalog-migration.test.ts`, `doctor.test.ts`, `effort-policy.test.ts`, `fast-row-listing.test.ts`, `fast-row.test.ts`, `gather-routed-models-single-flight.test.ts`, `history-migration-guardian.test.ts`, `injection-model-api.test.ts`, `issue-452-empty-503.test.ts`, `issue-702-expired-replay-state.test.ts`, `issue-914-transport-attribution.test.ts`, `model-cache-generation-tombstone.test.ts`, `model-cache.test.ts`, `model-display-names-management-api.test.ts`, `model-metadata-sync.test.ts`, `model-visibility-management-api.test.ts`, `multi-agent-compat.test.ts`, `multi-agent-keep-native-v1.test.ts`, `native-alias-maintainer-regressions.test.ts`, `native-claude-code-toggle.test.ts`, `native-claude-desktop-toggle.test.ts`, `native-codex-toggle.test.ts`, `native-grok-toggle.test.ts`, `native-main-auth-temp.test.ts`, `native-main-claim-cache.test.ts`, `native-main-claim.test.ts`, `native-main-owner-lifetime.test.ts`, `native-model-toggle.test.ts`, `native-profile-api.test.ts`, `native-profile-crash-boundaries.test.ts`, `native-profile-drain-server.test.ts`, `native-profile-manager.test.ts`, `native-profile-processes.test.ts`, `native-profile-recovery.test.ts`, `native-profile-route-security.test.ts`, `native-profile-stage-lifecycle.test.ts`, `native-profile-startup.test.ts`, `native-profile-store.test.ts`, `parallel-tool-calls-optin.test.ts`, `project-config-warnings.test.ts`, `reasoning-effort.test.ts`, `selected-models.test.ts`, `slug-codec.test.ts`, `token-guardian.test.ts`, `ultrafast-tier-honesty.test.ts`, `upstream-reachability.test.ts`, `warmup.test.ts` diff --git a/devlog/_plan/260906_aside_profiles/000_research.md b/devlog/_fin/260906_aside_profiles/000_research.md similarity index 100% rename from devlog/_plan/260906_aside_profiles/000_research.md rename to devlog/_fin/260906_aside_profiles/000_research.md diff --git a/devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md b/devlog/_fin/260906_aside_profiles/010_profiles_backend_cli.md similarity index 100% rename from devlog/_plan/260906_aside_profiles/010_profiles_backend_cli.md rename to devlog/_fin/260906_aside_profiles/010_profiles_backend_cli.md diff --git a/devlog/_plan/260906_aside_profiles/020_profiles_gui.md b/devlog/_fin/260906_aside_profiles/020_profiles_gui.md similarity index 100% rename from devlog/_plan/260906_aside_profiles/020_profiles_gui.md rename to devlog/_fin/260906_aside_profiles/020_profiles_gui.md diff --git a/devlog/_plan/260906_grok_catalog_and_patch/000_research.md b/devlog/_fin/260906_grok_catalog_and_patch/000_research.md similarity index 100% rename from devlog/_plan/260906_grok_catalog_and_patch/000_research.md rename to devlog/_fin/260906_grok_catalog_and_patch/000_research.md diff --git a/devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md b/devlog/_fin/260906_grok_catalog_and_patch/010_export_selection.md similarity index 100% rename from devlog/_plan/260906_grok_catalog_and_patch/010_export_selection.md rename to devlog/_fin/260906_grok_catalog_and_patch/010_export_selection.md diff --git a/devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md b/devlog/_fin/260906_grok_catalog_and_patch/020_owned_refresh.md similarity index 100% rename from devlog/_plan/260906_grok_catalog_and_patch/020_owned_refresh.md rename to devlog/_fin/260906_grok_catalog_and_patch/020_owned_refresh.md diff --git a/devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md b/devlog/_fin/260906_grok_catalog_and_patch/030_responses_patch.md similarity index 100% rename from devlog/_plan/260906_grok_catalog_and_patch/030_responses_patch.md rename to devlog/_fin/260906_grok_catalog_and_patch/030_responses_patch.md diff --git a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md b/devlog/_fin/260906_grok_catalog_and_patch/040_native_tool_parity.md similarity index 100% rename from devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md rename to devlog/_fin/260906_grok_catalog_and_patch/040_native_tool_parity.md diff --git a/devlog/_plan/260906_key_login_ci_timeout/000_plan.md b/devlog/_fin/260906_key_login_ci_timeout/000_plan.md similarity index 100% rename from devlog/_plan/260906_key_login_ci_timeout/000_plan.md rename to devlog/_fin/260906_key_login_ci_timeout/000_plan.md diff --git a/devlog/_plan/260906_release_244_followups/000_plan.md b/devlog/_fin/260906_release_244_followups/000_plan.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/000_plan.md rename to devlog/_fin/260906_release_244_followups/000_plan.md diff --git a/devlog/_plan/260906_release_244_followups/001_roadmap_lock.md b/devlog/_fin/260906_release_244_followups/001_roadmap_lock.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/001_roadmap_lock.md rename to devlog/_fin/260906_release_244_followups/001_roadmap_lock.md diff --git a/devlog/_plan/260906_release_244_followups/010_policy.md b/devlog/_fin/260906_release_244_followups/010_policy.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/010_policy.md rename to devlog/_fin/260906_release_244_followups/010_policy.md diff --git a/devlog/_plan/260906_release_244_followups/011_policy_implementation.md b/devlog/_fin/260906_release_244_followups/011_policy_implementation.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/011_policy_implementation.md rename to devlog/_fin/260906_release_244_followups/011_policy_implementation.md diff --git a/devlog/_plan/260906_release_244_followups/020_task_input.md b/devlog/_fin/260906_release_244_followups/020_task_input.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/020_task_input.md rename to devlog/_fin/260906_release_244_followups/020_task_input.md diff --git a/devlog/_plan/260906_release_244_followups/021_task_input_implementation.md b/devlog/_fin/260906_release_244_followups/021_task_input_implementation.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/021_task_input_implementation.md rename to devlog/_fin/260906_release_244_followups/021_task_input_implementation.md diff --git a/devlog/_plan/260906_release_244_followups/022_management_auth_port_fixture.md b/devlog/_fin/260906_release_244_followups/022_management_auth_port_fixture.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/022_management_auth_port_fixture.md rename to devlog/_fin/260906_release_244_followups/022_management_auth_port_fixture.md diff --git a/devlog/_plan/260906_release_244_followups/030_kiro_results.md b/devlog/_fin/260906_release_244_followups/030_kiro_results.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/030_kiro_results.md rename to devlog/_fin/260906_release_244_followups/030_kiro_results.md diff --git a/devlog/_plan/260906_release_244_followups/031_kiro_result_implementation.md b/devlog/_fin/260906_release_244_followups/031_kiro_result_implementation.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/031_kiro_result_implementation.md rename to devlog/_fin/260906_release_244_followups/031_kiro_result_implementation.md diff --git a/devlog/_plan/260906_release_244_followups/032_review_doc_format.md b/devlog/_fin/260906_release_244_followups/032_review_doc_format.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/032_review_doc_format.md rename to devlog/_fin/260906_release_244_followups/032_review_doc_format.md diff --git a/devlog/_plan/260906_release_244_followups/033_web_search_deadline_fixture.md b/devlog/_fin/260906_release_244_followups/033_web_search_deadline_fixture.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/033_web_search_deadline_fixture.md rename to devlog/_fin/260906_release_244_followups/033_web_search_deadline_fixture.md diff --git a/devlog/_plan/260906_release_244_followups/040_opaque_recovery.md b/devlog/_fin/260906_release_244_followups/040_opaque_recovery.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/040_opaque_recovery.md rename to devlog/_fin/260906_release_244_followups/040_opaque_recovery.md diff --git a/devlog/_plan/260906_release_244_followups/041_opaque_recovery_implementation.md b/devlog/_fin/260906_release_244_followups/041_opaque_recovery_implementation.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/041_opaque_recovery_implementation.md rename to devlog/_fin/260906_release_244_followups/041_opaque_recovery_implementation.md diff --git a/devlog/_plan/260906_release_244_followups/050_combo_recovery.md b/devlog/_fin/260906_release_244_followups/050_combo_recovery.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/050_combo_recovery.md rename to devlog/_fin/260906_release_244_followups/050_combo_recovery.md diff --git a/devlog/_plan/260906_release_244_followups/051_combo_recovery_implementation.md b/devlog/_fin/260906_release_244_followups/051_combo_recovery_implementation.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/051_combo_recovery_implementation.md rename to devlog/_fin/260906_release_244_followups/051_combo_recovery_implementation.md diff --git a/devlog/_plan/260906_release_244_followups/052_shutdown_fixture.md b/devlog/_fin/260906_release_244_followups/052_shutdown_fixture.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/052_shutdown_fixture.md rename to devlog/_fin/260906_release_244_followups/052_shutdown_fixture.md diff --git a/devlog/_plan/260906_release_244_followups/060_grok_terminal.md b/devlog/_fin/260906_release_244_followups/060_grok_terminal.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/060_grok_terminal.md rename to devlog/_fin/260906_release_244_followups/060_grok_terminal.md diff --git a/devlog/_plan/260906_release_244_followups/070_quota_proxy.md b/devlog/_fin/260906_release_244_followups/070_quota_proxy.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/070_quota_proxy.md rename to devlog/_fin/260906_release_244_followups/070_quota_proxy.md diff --git a/devlog/_plan/260906_release_244_followups/071_quota_proxy_outcome.md b/devlog/_fin/260906_release_244_followups/071_quota_proxy_outcome.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/071_quota_proxy_outcome.md rename to devlog/_fin/260906_release_244_followups/071_quota_proxy_outcome.md diff --git a/devlog/_plan/260906_release_244_followups/080_usage_source.md b/devlog/_fin/260906_release_244_followups/080_usage_source.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/080_usage_source.md rename to devlog/_fin/260906_release_244_followups/080_usage_source.md diff --git a/devlog/_plan/260906_release_244_followups/082_recovery_doc_alignment.md b/devlog/_fin/260906_release_244_followups/082_recovery_doc_alignment.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/082_recovery_doc_alignment.md rename to devlog/_fin/260906_release_244_followups/082_recovery_doc_alignment.md diff --git a/devlog/_plan/260906_release_244_followups/090_dashboard.md b/devlog/_fin/260906_release_244_followups/090_dashboard.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/090_dashboard.md rename to devlog/_fin/260906_release_244_followups/090_dashboard.md diff --git a/devlog/_plan/260906_release_244_followups/091_dashboard_verification.md b/devlog/_fin/260906_release_244_followups/091_dashboard_verification.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/091_dashboard_verification.md rename to devlog/_fin/260906_release_244_followups/091_dashboard_verification.md diff --git a/devlog/_plan/260906_release_244_followups/100_release.md b/devlog/_fin/260906_release_244_followups/100_release.md similarity index 100% rename from devlog/_plan/260906_release_244_followups/100_release.md rename to devlog/_fin/260906_release_244_followups/100_release.md diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1024.png b/devlog/_fin/260906_release_244_followups/screenshots/dashboard-1024.png similarity index 100% rename from devlog/_plan/260906_release_244_followups/screenshots/dashboard-1024.png rename to devlog/_fin/260906_release_244_followups/screenshots/dashboard-1024.png diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-dark.png b/devlog/_fin/260906_release_244_followups/screenshots/dashboard-1440-dark.png similarity index 100% rename from devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-dark.png rename to devlog/_fin/260906_release_244_followups/screenshots/dashboard-1440-dark.png diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-lower.png b/devlog/_fin/260906_release_244_followups/screenshots/dashboard-1440-lower.png similarity index 100% rename from devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-lower.png rename to devlog/_fin/260906_release_244_followups/screenshots/dashboard-1440-lower.png diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-zoom2.png b/devlog/_fin/260906_release_244_followups/screenshots/dashboard-1440-zoom2.png similarity index 100% rename from devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440-zoom2.png rename to devlog/_fin/260906_release_244_followups/screenshots/dashboard-1440-zoom2.png diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440.png b/devlog/_fin/260906_release_244_followups/screenshots/dashboard-1440.png similarity index 100% rename from devlog/_plan/260906_release_244_followups/screenshots/dashboard-1440.png rename to devlog/_fin/260906_release_244_followups/screenshots/dashboard-1440.png diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-empty-repeat.png b/devlog/_fin/260906_release_244_followups/screenshots/dashboard-320-empty-repeat.png similarity index 100% rename from devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-empty-repeat.png rename to devlog/_fin/260906_release_244_followups/screenshots/dashboard-320-empty-repeat.png diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-interaction.png b/devlog/_fin/260906_release_244_followups/screenshots/dashboard-320-interaction.png similarity index 100% rename from devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-interaction.png rename to devlog/_fin/260906_release_244_followups/screenshots/dashboard-320-interaction.png diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-keyboard-open.png b/devlog/_fin/260906_release_244_followups/screenshots/dashboard-320-keyboard-open.png similarity index 100% rename from devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-keyboard-open.png rename to devlog/_fin/260906_release_244_followups/screenshots/dashboard-320-keyboard-open.png diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-lower.png b/devlog/_fin/260906_release_244_followups/screenshots/dashboard-320-lower.png similarity index 100% rename from devlog/_plan/260906_release_244_followups/screenshots/dashboard-320-lower.png rename to devlog/_fin/260906_release_244_followups/screenshots/dashboard-320-lower.png diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-320.png b/devlog/_fin/260906_release_244_followups/screenshots/dashboard-320.png similarity index 100% rename from devlog/_plan/260906_release_244_followups/screenshots/dashboard-320.png rename to devlog/_fin/260906_release_244_followups/screenshots/dashboard-320.png diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-390.png b/devlog/_fin/260906_release_244_followups/screenshots/dashboard-390.png similarity index 100% rename from devlog/_plan/260906_release_244_followups/screenshots/dashboard-390.png rename to devlog/_fin/260906_release_244_followups/screenshots/dashboard-390.png diff --git a/devlog/_plan/260906_release_244_followups/screenshots/dashboard-768.png b/devlog/_fin/260906_release_244_followups/screenshots/dashboard-768.png similarity index 100% rename from devlog/_plan/260906_release_244_followups/screenshots/dashboard-768.png rename to devlog/_fin/260906_release_244_followups/screenshots/dashboard-768.png diff --git a/devlog/_plan/260906_stateful_task_guidance/000_plan.md b/devlog/_fin/260906_stateful_task_guidance/000_plan.md similarity index 100% rename from devlog/_plan/260906_stateful_task_guidance/000_plan.md rename to devlog/_fin/260906_stateful_task_guidance/000_plan.md diff --git a/devlog/_plan/260906_stateful_task_guidance/010_raw_boundary.md b/devlog/_fin/260906_stateful_task_guidance/010_raw_boundary.md similarity index 100% rename from devlog/_plan/260906_stateful_task_guidance/010_raw_boundary.md rename to devlog/_fin/260906_stateful_task_guidance/010_raw_boundary.md diff --git a/devlog/_plan/260906_stateful_task_guidance/011_implementation.md b/devlog/_fin/260906_stateful_task_guidance/011_implementation.md similarity index 100% rename from devlog/_plan/260906_stateful_task_guidance/011_implementation.md rename to devlog/_fin/260906_stateful_task_guidance/011_implementation.md diff --git a/devlog/_plan/260906_unix_shim_ci_restore/000_plan.md b/devlog/_fin/260906_unix_shim_ci_restore/000_plan.md similarity index 100% rename from devlog/_plan/260906_unix_shim_ci_restore/000_plan.md rename to devlog/_fin/260906_unix_shim_ci_restore/000_plan.md diff --git a/devlog/_plan/260906_unix_shim_ci_restore/010_repair.md b/devlog/_fin/260906_unix_shim_ci_restore/010_repair.md similarity index 100% rename from devlog/_plan/260906_unix_shim_ci_restore/010_repair.md rename to devlog/_fin/260906_unix_shim_ci_restore/010_repair.md diff --git a/devlog/_plan/260907_lane_c/000_plan.md b/devlog/_fin/260907_lane_c/000_plan.md similarity index 100% rename from devlog/_plan/260907_lane_c/000_plan.md rename to devlog/_fin/260907_lane_c/000_plan.md diff --git a/devlog/_plan/260907_lane_c/010_web_search.md b/devlog/_fin/260907_lane_c/010_web_search.md similarity index 100% rename from devlog/_plan/260907_lane_c/010_web_search.md rename to devlog/_fin/260907_lane_c/010_web_search.md diff --git a/devlog/_plan/260907_lane_c/020_vision.md b/devlog/_fin/260907_lane_c/020_vision.md similarity index 100% rename from devlog/_plan/260907_lane_c/020_vision.md rename to devlog/_fin/260907_lane_c/020_vision.md diff --git a/devlog/_plan/260907_lane_c/030_health.md b/devlog/_fin/260907_lane_c/030_health.md similarity index 100% rename from devlog/_plan/260907_lane_c/030_health.md rename to devlog/_fin/260907_lane_c/030_health.md diff --git a/devlog/_plan/260907_lane_c/040_desktop.md b/devlog/_fin/260907_lane_c/040_desktop.md similarity index 100% rename from devlog/_plan/260907_lane_c/040_desktop.md rename to devlog/_fin/260907_lane_c/040_desktop.md diff --git a/devlog/_plan/260907_lane_c/050_fallback.md b/devlog/_fin/260907_lane_c/050_fallback.md similarity index 100% rename from devlog/_plan/260907_lane_c/050_fallback.md rename to devlog/_fin/260907_lane_c/050_fallback.md diff --git a/devlog/_plan/260907_lane_d/000_plan.md b/devlog/_fin/260907_lane_d/000_plan.md similarity index 100% rename from devlog/_plan/260907_lane_d/000_plan.md rename to devlog/_fin/260907_lane_d/000_plan.md diff --git a/devlog/_plan/260907_lane_d/001_roadmap_audit.md b/devlog/_fin/260907_lane_d/001_roadmap_audit.md similarity index 100% rename from devlog/_plan/260907_lane_d/001_roadmap_audit.md rename to devlog/_fin/260907_lane_d/001_roadmap_audit.md diff --git a/devlog/_plan/260907_lane_d/010_thinking.md b/devlog/_fin/260907_lane_d/010_thinking.md similarity index 100% rename from devlog/_plan/260907_lane_d/010_thinking.md rename to devlog/_fin/260907_lane_d/010_thinking.md diff --git a/devlog/_plan/260907_lane_d/020_receipt.md b/devlog/_fin/260907_lane_d/020_receipt.md similarity index 100% rename from devlog/_plan/260907_lane_d/020_receipt.md rename to devlog/_fin/260907_lane_d/020_receipt.md diff --git a/devlog/_plan/260907_lane_d/030_account_prices.md b/devlog/_fin/260907_lane_d/030_account_prices.md similarity index 100% rename from devlog/_plan/260907_lane_d/030_account_prices.md rename to devlog/_fin/260907_lane_d/030_account_prices.md diff --git a/devlog/_plan/260907_lane_d/040_price_editor.md b/devlog/_fin/260907_lane_d/040_price_editor.md similarity index 100% rename from devlog/_plan/260907_lane_d/040_price_editor.md rename to devlog/_fin/260907_lane_d/040_price_editor.md diff --git a/devlog/_plan/260907_lane_d/050_usage_ranges.md b/devlog/_fin/260907_lane_d/050_usage_ranges.md similarity index 100% rename from devlog/_plan/260907_lane_d/050_usage_ranges.md rename to devlog/_fin/260907_lane_d/050_usage_ranges.md diff --git a/devlog/_plan/260907_lane_d/060_delivery.md b/devlog/_fin/260907_lane_d/060_delivery.md similarity index 100% rename from devlog/_plan/260907_lane_d/060_delivery.md rename to devlog/_fin/260907_lane_d/060_delivery.md diff --git a/devlog/_plan/260907_next_release_recommendations/000_plan.md b/devlog/_fin/260907_next_release_recommendations/000_plan.md similarity index 98% rename from devlog/_plan/260907_next_release_recommendations/000_plan.md rename to devlog/_fin/260907_next_release_recommendations/000_plan.md index 3febda57eaf..fddd8d8f03a 100644 --- a/devlog/_plan/260907_next_release_recommendations/000_plan.md +++ b/devlog/_fin/260907_next_release_recommendations/000_plan.md @@ -1,6 +1,6 @@ # 000 — Plan: next-release recommendation report (wp1) -Unit: devlog/_plan/260907_next_release_recommendations +Unit: devlog/_fin/260907_next_release_recommendations Class: C2 (docs-only deliverable; research via read-only explorer lanes) Goal: rank 10–30 items to land on `dev` before the release after v2.46.0 (dev open at 2.47.0). diff --git a/devlog/_plan/260907_next_release_recommendations/010_recommendations.md b/devlog/_fin/260907_next_release_recommendations/010_recommendations.md similarity index 100% rename from devlog/_plan/260907_next_release_recommendations/010_recommendations.md rename to devlog/_fin/260907_next_release_recommendations/010_recommendations.md diff --git a/devlog/_plan/260907_platform_validation/000_plan.md b/devlog/_fin/260907_platform_validation/000_plan.md similarity index 100% rename from devlog/_plan/260907_platform_validation/000_plan.md rename to devlog/_fin/260907_platform_validation/000_plan.md diff --git a/devlog/_plan/260907_platform_validation/001_plan_audit.md b/devlog/_fin/260907_platform_validation/001_plan_audit.md similarity index 100% rename from devlog/_plan/260907_platform_validation/001_plan_audit.md rename to devlog/_fin/260907_platform_validation/001_plan_audit.md diff --git a/devlog/_plan/260907_platform_validation/010_oauth_teardown.md b/devlog/_fin/260907_platform_validation/010_oauth_teardown.md similarity index 100% rename from devlog/_plan/260907_platform_validation/010_oauth_teardown.md rename to devlog/_fin/260907_platform_validation/010_oauth_teardown.md diff --git a/devlog/_plan/260907_platform_validation/020_container_smoke.md b/devlog/_fin/260907_platform_validation/020_container_smoke.md similarity index 100% rename from devlog/_plan/260907_platform_validation/020_container_smoke.md rename to devlog/_fin/260907_platform_validation/020_container_smoke.md diff --git a/devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md b/devlog/_fin/260907_platform_validation/025_container_lifecycle_mode.md similarity index 100% rename from devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md rename to devlog/_fin/260907_platform_validation/025_container_lifecycle_mode.md diff --git a/devlog/_plan/260907_platform_validation/030_container_ci.md b/devlog/_fin/260907_platform_validation/030_container_ci.md similarity index 100% rename from devlog/_plan/260907_platform_validation/030_container_ci.md rename to devlog/_fin/260907_platform_validation/030_container_ci.md diff --git a/devlog/_plan/260907_platform_validation/035_body_diagnostics.md b/devlog/_fin/260907_platform_validation/035_body_diagnostics.md similarity index 100% rename from devlog/_plan/260907_platform_validation/035_body_diagnostics.md rename to devlog/_fin/260907_platform_validation/035_body_diagnostics.md diff --git a/devlog/_plan/260907_platform_validation/040_residual_evidence.md b/devlog/_fin/260907_platform_validation/040_residual_evidence.md similarity index 100% rename from devlog/_plan/260907_platform_validation/040_residual_evidence.md rename to devlog/_fin/260907_platform_validation/040_residual_evidence.md diff --git a/devlog/_plan/260907_release_note_prefix/010_implementation.md b/devlog/_fin/260907_release_note_prefix/010_implementation.md similarity index 100% rename from devlog/_plan/260907_release_note_prefix/010_implementation.md rename to devlog/_fin/260907_release_note_prefix/010_implementation.md diff --git a/devlog/_plan/260907_release_train/000_plan.md b/devlog/_fin/260907_release_train/000_plan.md similarity index 98% rename from devlog/_plan/260907_release_train/000_plan.md rename to devlog/_fin/260907_release_train/000_plan.md index fc5510ad37d..fb18a3d2612 100644 --- a/devlog/_plan/260907_release_train/000_plan.md +++ b/devlog/_fin/260907_release_train/000_plan.md @@ -1,6 +1,6 @@ # 000 — Release train 260907: land ranked recommendations on dev (loop-in-loop) -Source of items: `devlog/_plan/260907_next_release_recommendations/010_recommendations.md` (27 ranked items). +Source of items: `devlog/_fin/260907_next_release_recommendations/010_recommendations.md` (27 ranked items). Base: `origin/dev@ece556a6e` (2.47.0). Goalplan: `release-train-260907-land-ranked-recommendations`. Class: C4 (release train; admin merges; contributor credit). Full PABCD per work-phase; delegated threads run their own cxc-loop. diff --git a/devlog/_plan/260907_release_train/010_wp1_execution.md b/devlog/_fin/260907_release_train/010_wp1_execution.md similarity index 100% rename from devlog/_plan/260907_release_train/010_wp1_execution.md rename to devlog/_fin/260907_release_train/010_wp1_execution.md diff --git a/devlog/_plan/260907_release_train/090_readiness.md b/devlog/_fin/260907_release_train/090_readiness.md similarity index 100% rename from devlog/_plan/260907_release_train/090_readiness.md rename to devlog/_fin/260907_release_train/090_readiness.md diff --git a/devlog/_plan/260907_release_train_b/000_plan.md b/devlog/_fin/260907_release_train_b/000_plan.md similarity index 100% rename from devlog/_plan/260907_release_train_b/000_plan.md rename to devlog/_fin/260907_release_train_b/000_plan.md diff --git a/devlog/_plan/260907_release_train_b/010_carry.md b/devlog/_fin/260907_release_train_b/010_carry.md similarity index 100% rename from devlog/_plan/260907_release_train_b/010_carry.md rename to devlog/_fin/260907_release_train_b/010_carry.md diff --git a/devlog/_plan/260907_release_train_b/020_carry.md b/devlog/_fin/260907_release_train_b/020_carry.md similarity index 100% rename from devlog/_plan/260907_release_train_b/020_carry.md rename to devlog/_fin/260907_release_train_b/020_carry.md diff --git a/devlog/_plan/260907_router_selection_capture/010_implementation.md b/devlog/_fin/260907_router_selection_capture/010_implementation.md similarity index 100% rename from devlog/_plan/260907_router_selection_capture/010_implementation.md rename to devlog/_fin/260907_router_selection_capture/010_implementation.md diff --git a/devlog/_plan/260907_sponsor_branches/000_plan.md b/devlog/_fin/260907_sponsor_branches/000_plan.md similarity index 100% rename from devlog/_plan/260907_sponsor_branches/000_plan.md rename to devlog/_fin/260907_sponsor_branches/000_plan.md diff --git a/devlog/_plan/260907_sponsor_branches/010_phase1.md b/devlog/_fin/260907_sponsor_branches/010_phase1.md similarity index 100% rename from devlog/_plan/260907_sponsor_branches/010_phase1.md rename to devlog/_fin/260907_sponsor_branches/010_phase1.md diff --git a/devlog/_plan/260907_sponsor_branches/020_phase2.md b/devlog/_fin/260907_sponsor_branches/020_phase2.md similarity index 100% rename from devlog/_plan/260907_sponsor_branches/020_phase2.md rename to devlog/_fin/260907_sponsor_branches/020_phase2.md diff --git a/devlog/_plan/260907_sponsor_branches/030_phase3.md b/devlog/_fin/260907_sponsor_branches/030_phase3.md similarity index 100% rename from devlog/_plan/260907_sponsor_branches/030_phase3.md rename to devlog/_fin/260907_sponsor_branches/030_phase3.md diff --git a/devlog/_plan/260907_track2_protocol/000_plan.md b/devlog/_fin/260907_track2_protocol/000_plan.md similarity index 100% rename from devlog/_plan/260907_track2_protocol/000_plan.md rename to devlog/_fin/260907_track2_protocol/000_plan.md diff --git a/devlog/_plan/260907_track2_protocol/010_chat_json_sse.md b/devlog/_fin/260907_track2_protocol/010_chat_json_sse.md similarity index 100% rename from devlog/_plan/260907_track2_protocol/010_chat_json_sse.md rename to devlog/_fin/260907_track2_protocol/010_chat_json_sse.md diff --git a/devlog/_plan/260907_track2_protocol/011_chat_audit_amendment.md b/devlog/_fin/260907_track2_protocol/011_chat_audit_amendment.md similarity index 100% rename from devlog/_plan/260907_track2_protocol/011_chat_audit_amendment.md rename to devlog/_fin/260907_track2_protocol/011_chat_audit_amendment.md diff --git a/devlog/_plan/260907_track2_protocol/020_refusal.md b/devlog/_fin/260907_track2_protocol/020_refusal.md similarity index 100% rename from devlog/_plan/260907_track2_protocol/020_refusal.md rename to devlog/_fin/260907_track2_protocol/020_refusal.md diff --git a/devlog/_plan/260907_track2_protocol/030_custom_efforts.md b/devlog/_fin/260907_track2_protocol/030_custom_efforts.md similarity index 100% rename from devlog/_plan/260907_track2_protocol/030_custom_efforts.md rename to devlog/_fin/260907_track2_protocol/030_custom_efforts.md diff --git a/devlog/_plan/260907_track2_protocol/040_hosted_search_disposition.md b/devlog/_fin/260907_track2_protocol/040_hosted_search_disposition.md similarity index 100% rename from devlog/_plan/260907_track2_protocol/040_hosted_search_disposition.md rename to devlog/_fin/260907_track2_protocol/040_hosted_search_disposition.md diff --git a/devlog/_plan/260907_track2_protocol/050_claude_compatibility.md b/devlog/_fin/260907_track2_protocol/050_claude_compatibility.md similarity index 100% rename from devlog/_plan/260907_track2_protocol/050_claude_compatibility.md rename to devlog/_fin/260907_track2_protocol/050_claude_compatibility.md diff --git a/devlog/_plan/260907_track2_protocol/060_remote_delivery.md b/devlog/_fin/260907_track2_protocol/060_remote_delivery.md similarity index 100% rename from devlog/_plan/260907_track2_protocol/060_remote_delivery.md rename to devlog/_fin/260907_track2_protocol/060_remote_delivery.md diff --git a/devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md b/devlog/_fin/260907_track2_protocol/070_windows_fixture_cache.md similarity index 100% rename from devlog/_plan/260907_track2_protocol/070_windows_fixture_cache.md rename to devlog/_fin/260907_track2_protocol/070_windows_fixture_cache.md diff --git a/devlog/_plan/260907_track2_protocol/080_windows_sync_preparation.md b/devlog/_fin/260907_track2_protocol/080_windows_sync_preparation.md similarity index 100% rename from devlog/_plan/260907_track2_protocol/080_windows_sync_preparation.md rename to devlog/_fin/260907_track2_protocol/080_windows_sync_preparation.md diff --git a/devlog/_plan/260907_track2_protocol/090_windows_shim_budget.md b/devlog/_fin/260907_track2_protocol/090_windows_shim_budget.md similarity index 100% rename from devlog/_plan/260907_track2_protocol/090_windows_shim_budget.md rename to devlog/_fin/260907_track2_protocol/090_windows_shim_budget.md diff --git a/devlog/_fin/260908_provider_runtime_stack/000_plan.md b/devlog/_fin/260908_provider_runtime_stack/000_plan.md index c8da992329c..2942397ae96 100644 --- a/devlog/_fin/260908_provider_runtime_stack/000_plan.md +++ b/devlog/_fin/260908_provider_runtime_stack/000_plan.md @@ -1,6 +1,6 @@ # 000 — Plan and live manifest -Unit: `devlog/_plan/260908_provider_runtime_stack`. Session `01a080e2-1dfc-7082-bff8-5043215bdd35`. +Unit: `devlog/_fin/260908_provider_runtime_stack`. Session `01a080e2-1dfc-7082-bff8-5043215bdd35`. Snapshot: 2026-09-08T12:00Z (fetch), `origin/dev` = `29bb221c3` (`Merge pull request #4021 from lidge-jun/codex/release-248-record`). Carry worktree: `/private/tmp/ocx-prs-stack-01a080e2` (linked worktree of the main checkout; diff --git a/devlog/_plan/260908_sponsor_overview/010_overview.md b/devlog/_fin/260908_sponsor_overview/010_overview.md similarity index 100% rename from devlog/_plan/260908_sponsor_overview/010_overview.md rename to devlog/_fin/260908_sponsor_overview/010_overview.md diff --git a/devlog/_plan/260908_voice_relay/000_plan.md b/devlog/_fin/260908_voice_relay/000_plan.md similarity index 100% rename from devlog/_plan/260908_voice_relay/000_plan.md rename to devlog/_fin/260908_voice_relay/000_plan.md diff --git a/devlog/_plan/260908_voice_relay/001_sources.md b/devlog/_fin/260908_voice_relay/001_sources.md similarity index 100% rename from devlog/_plan/260908_voice_relay/001_sources.md rename to devlog/_fin/260908_voice_relay/001_sources.md diff --git a/devlog/_plan/260909_codex_credential_health_chain/000_plan.md b/devlog/_fin/260909_codex_credential_health_chain/000_plan.md similarity index 100% rename from devlog/_plan/260909_codex_credential_health_chain/000_plan.md rename to devlog/_fin/260909_codex_credential_health_chain/000_plan.md diff --git a/devlog/_plan/260909_codex_credential_health_chain/010_wp1_terminal_verdict.md b/devlog/_fin/260909_codex_credential_health_chain/010_wp1_terminal_verdict.md similarity index 100% rename from devlog/_plan/260909_codex_credential_health_chain/010_wp1_terminal_verdict.md rename to devlog/_fin/260909_codex_credential_health_chain/010_wp1_terminal_verdict.md diff --git a/devlog/_plan/260909_codex_credential_health_chain/020_wp2_quota_registration.md b/devlog/_fin/260909_codex_credential_health_chain/020_wp2_quota_registration.md similarity index 100% rename from devlog/_plan/260909_codex_credential_health_chain/020_wp2_quota_registration.md rename to devlog/_fin/260909_codex_credential_health_chain/020_wp2_quota_registration.md diff --git a/devlog/_plan/260909_codex_credential_health_chain/030_wp3_anthropic_plan.md b/devlog/_fin/260909_codex_credential_health_chain/030_wp3_anthropic_plan.md similarity index 100% rename from devlog/_plan/260909_codex_credential_health_chain/030_wp3_anthropic_plan.md rename to devlog/_fin/260909_codex_credential_health_chain/030_wp3_anthropic_plan.md diff --git a/devlog/_plan/260909_config_mutation_lock_windows/000_plan.md b/devlog/_fin/260909_config_mutation_lock_windows/000_plan.md similarity index 100% rename from devlog/_plan/260909_config_mutation_lock_windows/000_plan.md rename to devlog/_fin/260909_config_mutation_lock_windows/000_plan.md diff --git a/devlog/_plan/260909_mid_thread_agent_task_recovery/000_plan.md b/devlog/_fin/260909_mid_thread_agent_task_recovery/000_plan.md similarity index 100% rename from devlog/_plan/260909_mid_thread_agent_task_recovery/000_plan.md rename to devlog/_fin/260909_mid_thread_agent_task_recovery/000_plan.md diff --git a/devlog/_plan/260909_post249_scope_cleanup/000_plan.md b/devlog/_fin/260909_post249_scope_cleanup/000_plan.md similarity index 100% rename from devlog/_plan/260909_post249_scope_cleanup/000_plan.md rename to devlog/_fin/260909_post249_scope_cleanup/000_plan.md diff --git a/devlog/_plan/260909_post249_scope_cleanup/010_reconciliation.md b/devlog/_fin/260909_post249_scope_cleanup/010_reconciliation.md similarity index 100% rename from devlog/_plan/260909_post249_scope_cleanup/010_reconciliation.md rename to devlog/_fin/260909_post249_scope_cleanup/010_reconciliation.md diff --git a/devlog/_plan/260909_post249_scope_cleanup/020_branch_worktree_cleanup.md b/devlog/_fin/260909_post249_scope_cleanup/020_branch_worktree_cleanup.md similarity index 100% rename from devlog/_plan/260909_post249_scope_cleanup/020_branch_worktree_cleanup.md rename to devlog/_fin/260909_post249_scope_cleanup/020_branch_worktree_cleanup.md diff --git a/devlog/_plan/260909_post249_scope_cleanup/030_issue_disposition.md b/devlog/_fin/260909_post249_scope_cleanup/030_issue_disposition.md similarity index 100% rename from devlog/_plan/260909_post249_scope_cleanup/030_issue_disposition.md rename to devlog/_fin/260909_post249_scope_cleanup/030_issue_disposition.md diff --git a/devlog/_plan/260909_post249_scope_cleanup/040_dispatch_roadmap.md b/devlog/_fin/260909_post249_scope_cleanup/040_dispatch_roadmap.md similarity index 100% rename from devlog/_plan/260909_post249_scope_cleanup/040_dispatch_roadmap.md rename to devlog/_fin/260909_post249_scope_cleanup/040_dispatch_roadmap.md diff --git a/devlog/_plan/260909_post249_scope_cleanup/050_delivery_record.md b/devlog/_fin/260909_post249_scope_cleanup/050_delivery_record.md similarity index 100% rename from devlog/_plan/260909_post249_scope_cleanup/050_delivery_record.md rename to devlog/_fin/260909_post249_scope_cleanup/050_delivery_record.md diff --git a/devlog/_plan/260909_usage_custom_range_disclosure/000_plan.md b/devlog/_fin/260909_usage_custom_range_disclosure/000_plan.md similarity index 100% rename from devlog/_plan/260909_usage_custom_range_disclosure/000_plan.md rename to devlog/_fin/260909_usage_custom_range_disclosure/000_plan.md diff --git a/devlog/_plan/260909_usage_custom_range_disclosure/010_audit.md b/devlog/_fin/260909_usage_custom_range_disclosure/010_audit.md similarity index 100% rename from devlog/_plan/260909_usage_custom_range_disclosure/010_audit.md rename to devlog/_fin/260909_usage_custom_range_disclosure/010_audit.md diff --git a/devlog/_plan/260909_usage_custom_range_disclosure/assets/010_before.png b/devlog/_fin/260909_usage_custom_range_disclosure/assets/010_before.png similarity index 100% rename from devlog/_plan/260909_usage_custom_range_disclosure/assets/010_before.png rename to devlog/_fin/260909_usage_custom_range_disclosure/assets/010_before.png diff --git a/devlog/_plan/260909_usage_custom_range_disclosure/assets/020_after_collapsed.png b/devlog/_fin/260909_usage_custom_range_disclosure/assets/020_after_collapsed.png similarity index 100% rename from devlog/_plan/260909_usage_custom_range_disclosure/assets/020_after_collapsed.png rename to devlog/_fin/260909_usage_custom_range_disclosure/assets/020_after_collapsed.png diff --git a/devlog/_plan/260909_usage_custom_range_disclosure/assets/030_after_open.png b/devlog/_fin/260909_usage_custom_range_disclosure/assets/030_after_open.png similarity index 100% rename from devlog/_plan/260909_usage_custom_range_disclosure/assets/030_after_open.png rename to devlog/_fin/260909_usage_custom_range_disclosure/assets/030_after_open.png diff --git a/devlog/_plan/260909_xai_oauth_retry_hardening/000_plan.md b/devlog/_fin/260909_xai_oauth_retry_hardening/000_plan.md similarity index 98% rename from devlog/_plan/260909_xai_oauth_retry_hardening/000_plan.md rename to devlog/_fin/260909_xai_oauth_retry_hardening/000_plan.md index 785622752e2..6a4728d9e68 100644 --- a/devlog/_plan/260909_xai_oauth_retry_hardening/000_plan.md +++ b/devlog/_fin/260909_xai_oauth_retry_hardening/000_plan.md @@ -60,7 +60,7 @@ trusting unbounded subdomains. - Tool/credential scope: `gh` as the repository owner for reads and PR creation on `lidge-jun/opencodex`; no merge, release, or settings writes. - Write scope: `src/oauth/xai.ts`, `tests/providers/xai/xai-oauth-retry.test.ts`, - `devlog/_plan/260909_xai_oauth_retry_hardening/`, `.tmp/` scratch. Git + `devlog/_fin/260909_xai_oauth_retry_hardening/`, `.tmp/` scratch. Git mutations use `-c core.hooksPath=/dev/null`; pushes use `--no-verify`. - Token/cost and wall-clock budgets: none set by the user; unbounded within the session, reported at handoff. diff --git a/devlog/_plan/260909_xai_oauth_retry_hardening/010_phase1_retry_after_abort.md b/devlog/_fin/260909_xai_oauth_retry_hardening/010_phase1_retry_after_abort.md similarity index 100% rename from devlog/_plan/260909_xai_oauth_retry_hardening/010_phase1_retry_after_abort.md rename to devlog/_fin/260909_xai_oauth_retry_hardening/010_phase1_retry_after_abort.md diff --git a/devlog/_plan/260909_xai_oauth_retry_hardening/020_phase2_endpoint_validation.md b/devlog/_fin/260909_xai_oauth_retry_hardening/020_phase2_endpoint_validation.md similarity index 100% rename from devlog/_plan/260909_xai_oauth_retry_hardening/020_phase2_endpoint_validation.md rename to devlog/_fin/260909_xai_oauth_retry_hardening/020_phase2_endpoint_validation.md diff --git a/devlog/_plan/260910_250_regression_audit_release/000_plan.md b/devlog/_fin/260910_250_regression_audit_release/000_plan.md similarity index 100% rename from devlog/_plan/260910_250_regression_audit_release/000_plan.md rename to devlog/_fin/260910_250_regression_audit_release/000_plan.md diff --git a/devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md b/devlog/_fin/260910_250_regression_audit_release/010_audit_lanes.md similarity index 100% rename from devlog/_plan/260910_250_regression_audit_release/010_audit_lanes.md rename to devlog/_fin/260910_250_regression_audit_release/010_audit_lanes.md diff --git a/devlog/_plan/260910_250_regression_audit_release/020_release_plan.md b/devlog/_fin/260910_250_regression_audit_release/020_release_plan.md similarity index 100% rename from devlog/_plan/260910_250_regression_audit_release/020_release_plan.md rename to devlog/_fin/260910_250_regression_audit_release/020_release_plan.md diff --git a/devlog/_plan/260910_250_regression_audit_release/030_evidence.md b/devlog/_fin/260910_250_regression_audit_release/030_evidence.md similarity index 100% rename from devlog/_plan/260910_250_regression_audit_release/030_evidence.md rename to devlog/_fin/260910_250_regression_audit_release/030_evidence.md diff --git a/devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md b/devlog/_fin/260910_250_regression_audit_release/040_triage_protocol.md similarity index 100% rename from devlog/_plan/260910_250_regression_audit_release/040_triage_protocol.md rename to devlog/_fin/260910_250_regression_audit_release/040_triage_protocol.md diff --git a/devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md b/devlog/_fin/260910_250_regression_audit_release/050_lane_packets.md similarity index 100% rename from devlog/_plan/260910_250_regression_audit_release/050_lane_packets.md rename to devlog/_fin/260910_250_regression_audit_release/050_lane_packets.md diff --git a/devlog/_plan/260910_250_regression_audit_release/060_release_readiness.md b/devlog/_fin/260910_250_regression_audit_release/060_release_readiness.md similarity index 100% rename from devlog/_plan/260910_250_regression_audit_release/060_release_readiness.md rename to devlog/_fin/260910_250_regression_audit_release/060_release_readiness.md diff --git a/devlog/_plan/260910_250_regression_audit_release/070_release_execution.md b/devlog/_fin/260910_250_regression_audit_release/070_release_execution.md similarity index 100% rename from devlog/_plan/260910_250_regression_audit_release/070_release_execution.md rename to devlog/_fin/260910_250_regression_audit_release/070_release_execution.md diff --git a/devlog/_plan/260910_250_regression_audit_release/080_delivery_record.md b/devlog/_fin/260910_250_regression_audit_release/080_delivery_record.md similarity index 100% rename from devlog/_plan/260910_250_regression_audit_release/080_delivery_record.md rename to devlog/_fin/260910_250_regression_audit_release/080_delivery_record.md diff --git a/devlog/_plan/260910_cursor_acp_bridge/000_plan.md b/devlog/_fin/260910_cursor_acp_bridge/000_plan.md similarity index 97% rename from devlog/_plan/260910_cursor_acp_bridge/000_plan.md rename to devlog/_fin/260910_cursor_acp_bridge/000_plan.md index 47480b5e671..07bbe21b4cf 100644 --- a/devlog/_plan/260910_cursor_acp_bridge/000_plan.md +++ b/devlog/_fin/260910_cursor_acp_bridge/000_plan.md @@ -70,7 +70,7 @@ Each phase consumes the verified output of the previous one. | `050_audit.md` | independent reviewer verdict and main disposition | A phase | | `060_done.md` | cycle summary, what did not improve, falsification hooks | D phase | -No file outside `devlog/_plan/260910_cursor_acp_bridge/` is touched. +No file outside `devlog/_fin/260910_cursor_acp_bridge/` is touched. ## Accept criteria @@ -101,7 +101,7 @@ Run before being written here. | Command | Exit | Reads this unit's target? | |---|---|---| -| `bun run privacy:scan` | `0` ("Privacy scan passed", re-run 2026-09-10 after staging) | **Yes, but only once the files are tracked.** `scripts/privacy-scan.ts:60` takes its inputs from `git ls-files`. The first run of this gate passed while reading **none** of this unit's files, because they were untracked. The files were then `git add`-ed and the gate re-run; `git ls-files -- devlog/_plan/260910_cursor_acp_bridge` now lists all six, including `050_audit.md`, which was itself missed on the first staging pass. | +| `bun run privacy:scan` | `0` ("Privacy scan passed", re-run 2026-09-10 after staging) | **Yes, but only once the files are tracked.** `scripts/privacy-scan.ts:60` takes its inputs from `git ls-files`. The first run of this gate passed while reading **none** of this unit's files, because they were untracked. The files were then `git add`-ed and the gate re-run; `git ls-files -- devlog/_fin/260910_cursor_acp_bridge` now lists all six, including `050_audit.md`, which was itself missed on the first staging pass. | | `bun test tests/ci-workflows/repo-hygiene.test.ts` | `0` (14 pass / 0 fail, 2026-09-10) | **Yes, partially, and also only when tracked.** It asserts no `160000` gitlink and no excised material is in the index, and includes an unresolved-security-verdict tripwire over open devlog plans. It checks hygiene, not correctness. | **This table is itself an instance of the trap PLAN-VERIFIER-REAL-01 warns about** diff --git a/devlog/_plan/260910_cursor_acp_bridge/010_protocol_identity.md b/devlog/_fin/260910_cursor_acp_bridge/010_protocol_identity.md similarity index 100% rename from devlog/_plan/260910_cursor_acp_bridge/010_protocol_identity.md rename to devlog/_fin/260910_cursor_acp_bridge/010_protocol_identity.md diff --git a/devlog/_plan/260910_cursor_acp_bridge/020_cli_jaw_inventory.md b/devlog/_fin/260910_cursor_acp_bridge/020_cli_jaw_inventory.md similarity index 100% rename from devlog/_plan/260910_cursor_acp_bridge/020_cli_jaw_inventory.md rename to devlog/_fin/260910_cursor_acp_bridge/020_cli_jaw_inventory.md diff --git a/devlog/_plan/260910_cursor_acp_bridge/030_opencodex_seams.md b/devlog/_fin/260910_cursor_acp_bridge/030_opencodex_seams.md similarity index 100% rename from devlog/_plan/260910_cursor_acp_bridge/030_opencodex_seams.md rename to devlog/_fin/260910_cursor_acp_bridge/030_opencodex_seams.md diff --git a/devlog/_plan/260910_cursor_acp_bridge/040_feasibility_verdict.md b/devlog/_fin/260910_cursor_acp_bridge/040_feasibility_verdict.md similarity index 100% rename from devlog/_plan/260910_cursor_acp_bridge/040_feasibility_verdict.md rename to devlog/_fin/260910_cursor_acp_bridge/040_feasibility_verdict.md diff --git a/devlog/_plan/260910_cursor_acp_bridge/050_audit.md b/devlog/_fin/260910_cursor_acp_bridge/050_audit.md similarity index 99% rename from devlog/_plan/260910_cursor_acp_bridge/050_audit.md rename to devlog/_fin/260910_cursor_acp_bridge/050_audit.md index c8247785639..877b0ac167f 100644 --- a/devlog/_plan/260910_cursor_acp_bridge/050_audit.md +++ b/devlog/_fin/260910_cursor_acp_bridge/050_audit.md @@ -83,7 +83,7 @@ of them, and the plan's claim that it "observes every file this unit adds" was false as written. Fixed properly rather than reworded: the unit was `git add`-ed, -`git ls-files -- devlog/_plan/260910_cursor_acp_bridge` now returns all five +`git ls-files -- devlog/_fin/260910_cursor_acp_bridge` now returns all five files, and both gates were re-run against the tracked state (`privacy:scan` exit 0; `repo-hygiene` 14 pass / 0 fail). 000 now records the original failure as a worked example of the PLAN-VERIFIER-REAL-01 trap. diff --git a/devlog/_plan/260910_cursor_acp_bridge/060_done.md b/devlog/_fin/260910_cursor_acp_bridge/060_done.md similarity index 99% rename from devlog/_plan/260910_cursor_acp_bridge/060_done.md rename to devlog/_fin/260910_cursor_acp_bridge/060_done.md index 10bcf0385de..115259c7031 100644 --- a/devlog/_plan/260910_cursor_acp_bridge/060_done.md +++ b/devlog/_fin/260910_cursor_acp_bridge/060_done.md @@ -105,7 +105,7 @@ Over the tracked six-file unit: - `bun run privacy:scan` -> exit 0, "Privacy scan passed" - `bun test tests/ci-workflows/repo-hygiene.test.ts` -> 14 pass / 0 fail -- `git ls-files -- devlog/_plan/260910_cursor_acp_bridge` -> 6 files +- `git ls-files -- devlog/_fin/260910_cursor_acp_bridge` -> 6 files No `src/`, `gui/` or `docs-site/` file was touched, so no typecheck or product suite applies. **No machine gate checks whether any claim in these documents is diff --git a/devlog/_plan/260910_cursor_acp_bridge/070_live_trace.md b/devlog/_fin/260910_cursor_acp_bridge/070_live_trace.md similarity index 100% rename from devlog/_plan/260910_cursor_acp_bridge/070_live_trace.md rename to devlog/_fin/260910_cursor_acp_bridge/070_live_trace.md diff --git a/devlog/_plan/260910_live_service_manager_guard/000_plan.md b/devlog/_fin/260910_live_service_manager_guard/000_plan.md similarity index 97% rename from devlog/_plan/260910_live_service_manager_guard/000_plan.md rename to devlog/_fin/260910_live_service_manager_guard/000_plan.md index 817da13a7d9..39b5fce6c6c 100644 --- a/devlog/_plan/260910_live_service_manager_guard/000_plan.md +++ b/devlog/_fin/260910_live_service_manager_guard/000_plan.md @@ -53,4 +53,4 @@ machine this was written on is running opencodex. CI on the pushed head is the e The incident that started this was an agent executing README text through a shell. This guard would not have stopped it. That belongs to how agents write files, and it is recorded in -`devlog/_plan/260910_readme_i18n_parity/020_phase2_locale_resync.md`. +`devlog/_fin/260910_readme_i18n_parity/020_phase2_locale_resync.md`. diff --git a/devlog/_plan/260910_post249_round2/000_plan.md b/devlog/_fin/260910_post249_round2/000_plan.md similarity index 98% rename from devlog/_plan/260910_post249_round2/000_plan.md rename to devlog/_fin/260910_post249_round2/000_plan.md index 46ca0bc9c26..a2fa3b69741 100644 --- a/devlog/_plan/260910_post249_round2/000_plan.md +++ b/devlog/_fin/260910_post249_round2/000_plan.md @@ -2,7 +2,7 @@ ## Context -Round 1 (`devlog/_plan/260909_post249_scope_cleanup/`) landed 17 PRs and closed +Round 1 (`devlog/_fin/260909_post249_scope_cleanup/`) landed 17 PRs and closed 16 issues; `origin/dev` closed at `cd813d3d9`. Three issues were left open by decision: #3978 (deferred until the compaction status contract settles), #3506 (direction comment only — translation fidelity, not a proxy-side progress diff --git a/devlog/_plan/260910_post249_round2/010_lane_split.md b/devlog/_fin/260910_post249_round2/010_lane_split.md similarity index 100% rename from devlog/_plan/260910_post249_round2/010_lane_split.md rename to devlog/_fin/260910_post249_round2/010_lane_split.md diff --git a/devlog/_plan/260910_post249_round2/020_4129_shadow_combo_failover.md b/devlog/_fin/260910_post249_round2/020_4129_shadow_combo_failover.md similarity index 100% rename from devlog/_plan/260910_post249_round2/020_4129_shadow_combo_failover.md rename to devlog/_fin/260910_post249_round2/020_4129_shadow_combo_failover.md diff --git a/devlog/_plan/260910_post249_round2/030_4148_claude_system_hoist.md b/devlog/_fin/260910_post249_round2/030_4148_claude_system_hoist.md similarity index 100% rename from devlog/_plan/260910_post249_round2/030_4148_claude_system_hoist.md rename to devlog/_fin/260910_post249_round2/030_4148_claude_system_hoist.md diff --git a/devlog/_plan/260910_post249_round2/040_4141_launchctl_bootout.md b/devlog/_fin/260910_post249_round2/040_4141_launchctl_bootout.md similarity index 100% rename from devlog/_plan/260910_post249_round2/040_4141_launchctl_bootout.md rename to devlog/_fin/260910_post249_round2/040_4141_launchctl_bootout.md diff --git a/devlog/_plan/260910_post249_round2/050_3666_free_model_filter.md b/devlog/_fin/260910_post249_round2/050_3666_free_model_filter.md similarity index 100% rename from devlog/_plan/260910_post249_round2/050_3666_free_model_filter.md rename to devlog/_fin/260910_post249_round2/050_3666_free_model_filter.md diff --git a/devlog/_plan/260910_post249_round2/060_4075_gemini_setup_ux.md b/devlog/_fin/260910_post249_round2/060_4075_gemini_setup_ux.md similarity index 100% rename from devlog/_plan/260910_post249_round2/060_4075_gemini_setup_ux.md rename to devlog/_fin/260910_post249_round2/060_4075_gemini_setup_ux.md diff --git a/devlog/_plan/260910_post249_round2/070_3859_email_mask_toggle.md b/devlog/_fin/260910_post249_round2/070_3859_email_mask_toggle.md similarity index 100% rename from devlog/_plan/260910_post249_round2/070_3859_email_mask_toggle.md rename to devlog/_fin/260910_post249_round2/070_3859_email_mask_toggle.md diff --git a/devlog/_plan/260910_post249_round2/080_1711_zero_credit_catalog.md b/devlog/_fin/260910_post249_round2/080_1711_zero_credit_catalog.md similarity index 100% rename from devlog/_plan/260910_post249_round2/080_1711_zero_credit_catalog.md rename to devlog/_fin/260910_post249_round2/080_1711_zero_credit_catalog.md diff --git a/devlog/_plan/260910_post249_round2/090_4038_decode_rate.md b/devlog/_fin/260910_post249_round2/090_4038_decode_rate.md similarity index 100% rename from devlog/_plan/260910_post249_round2/090_4038_decode_rate.md rename to devlog/_fin/260910_post249_round2/090_4038_decode_rate.md diff --git a/devlog/_plan/260910_post249_round2/100_4147_zcode_reasoning_landing.md b/devlog/_fin/260910_post249_round2/100_4147_zcode_reasoning_landing.md similarity index 100% rename from devlog/_plan/260910_post249_round2/100_4147_zcode_reasoning_landing.md rename to devlog/_fin/260910_post249_round2/100_4147_zcode_reasoning_landing.md diff --git a/devlog/_plan/260910_post249_round2/110_delivery_record.md b/devlog/_fin/260910_post249_round2/110_delivery_record.md similarity index 100% rename from devlog/_plan/260910_post249_round2/110_delivery_record.md rename to devlog/_fin/260910_post249_round2/110_delivery_record.md diff --git a/devlog/_plan/260910_post249_round2/120_landing_gate_decision.md b/devlog/_fin/260910_post249_round2/120_landing_gate_decision.md similarity index 100% rename from devlog/_plan/260910_post249_round2/120_landing_gate_decision.md rename to devlog/_fin/260910_post249_round2/120_landing_gate_decision.md diff --git a/devlog/_plan/260910_post249_round2/_research/1711.md b/devlog/_fin/260910_post249_round2/_research/1711.md similarity index 100% rename from devlog/_plan/260910_post249_round2/_research/1711.md rename to devlog/_fin/260910_post249_round2/_research/1711.md diff --git a/devlog/_plan/260910_post249_round2/_research/3666.md b/devlog/_fin/260910_post249_round2/_research/3666.md similarity index 100% rename from devlog/_plan/260910_post249_round2/_research/3666.md rename to devlog/_fin/260910_post249_round2/_research/3666.md diff --git a/devlog/_plan/260910_post249_round2/_research/3859.md b/devlog/_fin/260910_post249_round2/_research/3859.md similarity index 100% rename from devlog/_plan/260910_post249_round2/_research/3859.md rename to devlog/_fin/260910_post249_round2/_research/3859.md diff --git a/devlog/_plan/260910_post249_round2/_research/4038.md b/devlog/_fin/260910_post249_round2/_research/4038.md similarity index 100% rename from devlog/_plan/260910_post249_round2/_research/4038.md rename to devlog/_fin/260910_post249_round2/_research/4038.md diff --git a/devlog/_plan/260910_post249_round2/_research/4075.md b/devlog/_fin/260910_post249_round2/_research/4075.md similarity index 100% rename from devlog/_plan/260910_post249_round2/_research/4075.md rename to devlog/_fin/260910_post249_round2/_research/4075.md diff --git a/devlog/_plan/260910_post249_round2/_research/4129.md b/devlog/_fin/260910_post249_round2/_research/4129.md similarity index 100% rename from devlog/_plan/260910_post249_round2/_research/4129.md rename to devlog/_fin/260910_post249_round2/_research/4129.md diff --git a/devlog/_plan/260910_post249_round2/_research/4141.md b/devlog/_fin/260910_post249_round2/_research/4141.md similarity index 100% rename from devlog/_plan/260910_post249_round2/_research/4141.md rename to devlog/_fin/260910_post249_round2/_research/4141.md diff --git a/devlog/_plan/260910_post249_round2/_research/4148.md b/devlog/_fin/260910_post249_round2/_research/4148.md similarity index 100% rename from devlog/_plan/260910_post249_round2/_research/4148.md rename to devlog/_fin/260910_post249_round2/_research/4148.md diff --git a/devlog/_fin/260910_post249_round2/_research/_audit.md b/devlog/_fin/260910_post249_round2/_research/_audit.md new file mode 100644 index 00000000000..e42e788f62e --- /dev/null +++ b/devlog/_fin/260910_post249_round2/_research/_audit.md @@ -0,0 +1,30 @@ +1. **BLOCKER** [000_plan.md:17](devlog/_fin/260910_post249_round2/000_plan.md) vs the decade docs: the round claims “Eight issues, all selected because the fix is mechanical and needs no maintainer policy judgment.” Three lane docs contradict that on their face: [040_a3_1711_zero_credit_catalog.md:3](devlog/_fin/260910_post249_round2/040_a3_1711_zero_credit_catalog.md) “Scope is pending a user decision”; [060_b1_4038_decode_rate.md:3](devlog/_fin/260910_post249_round2/060_b1_4038_decode_rate.md) “This one is pending a user decision”; [080_b3_3859_email_mask_toggle.md:3](devlog/_fin/260910_post249_round2/080_b3_3859_email_mask_toggle.md) “Surface choice is pending a user decision.” Their `_research` files mark the same forks as `POLICY (not mechanical)` ([_research/1711.md:39](devlog/_fin/260910_post249_round2/_research/1711.md), [_research/4038.md:46](devlog/_fin/260910_post249_round2/_research/4038.md), [_research/3859.md:32](devlog/_fin/260910_post249_round2/_research/3859.md)). + +2. **BLOCKER** [030_a2_4148_claude_system_hoist.md:17](devlog/_fin/260910_post249_round2/030_a2_4148_claude_system_hoist.md): `src/responses/parser.ts:204-206` is cited as `systemPrompt.push(data.instructions)`. Those lines are the `context_compaction` encrypted-content path. Instructions are consumed at `parser.ts:144-145`. The same wrong span is in [_research/4148.md:23](devlog/_fin/260910_post249_round2/_research/4148.md). `parser.ts:246-250` and `:253-258` are correct. + +3. **SHOULD-FIX** [020_a1_4129_shadow_combo_failover.md:14](devlog/_fin/260910_post249_round2/020_a1_4129_shadow_combo_failover.md): `core.ts:2600` is cited as the `while (pick)` / `advanceComboAfterFailure` loop. Line 2600 is `export async function handleComboResponses`. The loop is at `:2798`; `advanceComboAfterFailure` is at `:3056`. Other 4129 anchors (`request.ts:20`, `core.ts:3306`, `:3480-3512`, `router.ts:674`, `resolve.ts:418`, `shadow-call.ts:70`/`:88`, `failover.ts:468`) match. + +4. **SHOULD-FIX** [040_a3_1711_zero_credit_catalog.md:55](devlog/_fin/260910_post249_round2/040_a3_1711_zero_credit_catalog.md): `comboQuotaState` is cited at `gui/src/combo-workspace-data.ts:369-411`. Those lines are `quotaStateFromReport` (the `remaining <= 0` without `percent >= 100` check is `:407`). `comboQuotaState` is at `:437-456`. Research splits this correctly in [_research/1711.md:27](devlog/_fin/260910_post249_round2/_research/1711.md) (`433-456`) vs [:45](devlog/_fin/260910_post249_round2/_research/1711.md) (`369-411`). The rest of the 1711 source anchors (`sync.ts:315`/`347`/`414`/`641`/`961`/`1181-1184`, `parsing.ts:96-149`, `resolve.ts:64-70`/`78-92`, `provider-fetch.ts:2067-2106`, `index.ts:1574-1598`) match. + +5. Remaining spot-checks that **do** point at the claimed code (no finding): 4141 `service.ts:2296-2324`/`3129-3131`/`919-921`, `service.test.ts:3141-3145`; 4038 `cost.ts:655-661`, `shared.ts:99-113`, `Logs.tsx:273-279`; 3666 `provider-fetch.ts:1404`/`1468`, `Models.tsx:1411` (search inside `renderGroup` at `:1386`); 3859 `privacy.ts:1-11`, `auth-api.ts:367`/`380`/`2011`/`3045`, `oauth/index.ts:1808`/`1815`/`1835`, `layout.json:958`; 4075 `Models.tsx:1410`/`1450-1458`/`1650-1651`, `registry.ts:1978`, `initial-model-selection.test.ts:278-285`. + +6. **BLOCKER** [050_a4_4141_launchctl_bootout.md:25](devlog/_fin/260910_post249_round2/050_a4_4141_launchctl_bootout.md): auto-`bootout` is presented as the chosen mechanical fix. [_research/4141.md:26-28](devlog/_fin/260910_post249_round2/_research/4141.md) labels “Auto-bootout vs today’s hint-only throw” as `POLICY (not mechanical)` because it kills the live gui job. The decade doc lists other policy deferrals (`stopLaunchd`, `startLaunchd`, `statusLaunchd`) but not this one. + +7. **SHOULD-FIX** [030_a2_4148_claude_system_hoist.md:40](devlog/_fin/260910_post249_round2/030_a2_4148_claude_system_hoist.md): “all in-messages system messages become developer items” is a `Scope decision`, which is honest locally, but [_research/4148.md:40-42](devlog/_fin/260910_post249_round2/_research/4148.md) marked that exact fork as POLICY. Combined with finding 1, the round still treats #4148 as mechanical. 4075 correctly keeps registry `liveModels` and pending-finalize out of the PR ([090_b4_4075_gemini_setup_ux.md:41](devlog/_fin/260910_post249_round2/090_b4_4075_gemini_setup_ux.md)). 3666’s fail-closed defaults are labeled “Decided defaults” ([070_b2_3666_free_model_filter.md:42](devlog/_fin/260910_post249_round2/070_b2_3666_free_model_filter.md)), matching [_research/3666.md:39-45](devlog/_fin/260910_post249_round2/_research/3666.md). + +8. **BLOCKER** [010_lane_split.md:10](devlog/_fin/260910_post249_round2/010_lane_split.md) / [:24-26](devlog/_fin/260910_post249_round2/010_lane_split.md): write sets are not disjoint, and #3666 is not a GUI-only reader. + + Shared files both lanes would edit: + + - `src/codex/catalog/parsing.ts` (`CatalogModel`): #1711 needs a served-row field; #3666 adds `pricingStatus` ([070_b2_3666_free_model_filter.md:30](devlog/_fin/260910_post249_round2/070_b2_3666_free_model_filter.md)). + - `src/codex/catalog/provider-fetch.ts`: #3666 writes `catalogHintsFromModelsApiItem`; #1711’s stamp/filter constraints live in the same file (`filterCatalogVisibleModels` at `:2067`). + - `gui/` if #1711 follows its own recommendation to “ship the field plus the Dashboard” ([040_a3_1711_zero_credit_catalog.md:37](devlog/_fin/260910_post249_round2/040_a3_1711_zero_credit_catalog.md)), colliding with #3666/`Models.tsx` and #4075. + - `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`: new tests from #1711 (`catalog-zero-credit-picker.test.ts`) and Lane B (`catalog-free-pricing-status.test.ts` and/or `models-discovery-failed-hint.test.ts`). + + 010 also misnames Lane A test dirs (`tests/claude/`, `tests/catalog/`); the actual homes are `tests/claude-integration/` and `tests/codex-integration/`, and #4129’s test is `tests/responses/`. + +9. **NOTE** test-layout mapping is otherwise right. `catalog-*` seeds to `codex-integration` ([scripts/test-layout/layout.json:36](scripts/test-layout/layout.json)); `models-*` seeds to `gui` (`:83`); `responses-shadow-intercept.test.ts` is already explicit (`:1070`); `privacy-mask-account.test.ts` is already explicit (`:958`). 020/030/050/060/080 correctly say “no new layout entry” when extending an existing file. 040/070/090 correctly require both `layout.json` `explicit` and `tests/fixtures/test-layout-expected.json` for new files. 070’s optional GUI file `tests/gui/models-free-filter.test.ts` (from research) would need the same pair if created. + +10. **NOTE** privacy: no non-`example.com` / `example.test` / `test.com` / `*.test` emails, no token-shaped secrets. `/Users/jun/...` links appear throughout `_research/*.md`; `privacy-scan` allows maintainer username `jun` under `devlog/` ([scripts/privacy-scan.ts:27](scripts/privacy-scan.ts), `:150`). + +**FAIL** diff --git a/devlog/_plan/260910_post249_round2/_research/_audit_pr4153.md b/devlog/_fin/260910_post249_round2/_research/_audit_pr4153.md similarity index 100% rename from devlog/_plan/260910_post249_round2/_research/_audit_pr4153.md rename to devlog/_fin/260910_post249_round2/_research/_audit_pr4153.md diff --git a/devlog/_plan/260910_post249_round2/_research/_audit_wp2.md b/devlog/_fin/260910_post249_round2/_research/_audit_wp2.md similarity index 100% rename from devlog/_plan/260910_post249_round2/_research/_audit_wp2.md rename to devlog/_fin/260910_post249_round2/_research/_audit_wp2.md diff --git a/devlog/_plan/260910_post249_round2/_research/_audit_wp3.md b/devlog/_fin/260910_post249_round2/_research/_audit_wp3.md similarity index 97% rename from devlog/_plan/260910_post249_round2/_research/_audit_wp3.md rename to devlog/_fin/260910_post249_round2/_research/_audit_wp3.md index 8217a9f9543..7b8a64bf179 100644 --- a/devlog/_plan/260910_post249_round2/_research/_audit_wp3.md +++ b/devlog/_fin/260910_post249_round2/_research/_audit_wp3.md @@ -1,4 +1,4 @@ -Read-only audit of #4156, #4158, and #4160 against `devlog/_plan/260910_post249_round2/`. Local product tests were not run. +Read-only audit of #4156, #4158, and #4160 against `devlog/_fin/260910_post249_round2/`. Local product tests were not run. ## #4156 — #3666 free-only filter diff --git a/devlog/_plan/260910_post249_round2/_research/_audit_wp3b.md b/devlog/_fin/260910_post249_round2/_research/_audit_wp3b.md similarity index 100% rename from devlog/_plan/260910_post249_round2/_research/_audit_wp3b.md rename to devlog/_fin/260910_post249_round2/_research/_audit_wp3b.md diff --git a/devlog/_plan/260910_readme_i18n_parity/000_plan.md b/devlog/_fin/260910_readme_i18n_parity/000_plan.md similarity index 100% rename from devlog/_plan/260910_readme_i18n_parity/000_plan.md rename to devlog/_fin/260910_readme_i18n_parity/000_plan.md diff --git a/devlog/_plan/260910_readme_i18n_parity/010_phase1_parity_guard.md b/devlog/_fin/260910_readme_i18n_parity/010_phase1_parity_guard.md similarity index 100% rename from devlog/_plan/260910_readme_i18n_parity/010_phase1_parity_guard.md rename to devlog/_fin/260910_readme_i18n_parity/010_phase1_parity_guard.md diff --git a/devlog/_plan/260910_readme_i18n_parity/020_phase2_locale_resync.md b/devlog/_fin/260910_readme_i18n_parity/020_phase2_locale_resync.md similarity index 100% rename from devlog/_plan/260910_readme_i18n_parity/020_phase2_locale_resync.md rename to devlog/_fin/260910_readme_i18n_parity/020_phase2_locale_resync.md diff --git a/devlog/_plan/260910_readme_i18n_parity/030_phase3_delivery.md b/devlog/_fin/260910_readme_i18n_parity/030_phase3_delivery.md similarity index 100% rename from devlog/_plan/260910_readme_i18n_parity/030_phase3_delivery.md rename to devlog/_fin/260910_readme_i18n_parity/030_phase3_delivery.md diff --git a/devlog/_plan/260911_account_pool_unification/000_plan.md b/devlog/_fin/260911_account_pool_unification/000_plan.md similarity index 99% rename from devlog/_plan/260911_account_pool_unification/000_plan.md rename to devlog/_fin/260911_account_pool_unification/000_plan.md index 203c540c424..2498dfe2528 100644 --- a/devlog/_plan/260911_account_pool_unification/000_plan.md +++ b/devlog/_fin/260911_account_pool_unification/000_plan.md @@ -70,7 +70,7 @@ Recorded during the 2026-09-11 interview (session tracker rounds 1-5): - `privacy:scan` must stay green. - Existing Codex and Anthropic pool behavior must not regress; they migrate onto the shared layer rather than being rewritten in place. -- **Lane ownership.** `devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md` +- **Lane ownership.** `devlog/_fin/260911_lane_dispatch_round/010_lane_partition.md` is the authoritative ownership list for the multi-lane round in flight on `dev`, and lane L3 owns `src/codex/auth-api.ts`, `src/codex/routing.ts` and `src/types/config.ts`. Work-phases 1 and 2 need those files, so no implementation diff --git a/devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md b/devlog/_fin/260911_account_pool_unification/010_phase1_manual_selection.md similarity index 100% rename from devlog/_plan/260911_account_pool_unification/010_phase1_manual_selection.md rename to devlog/_fin/260911_account_pool_unification/010_phase1_manual_selection.md diff --git a/devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md b/devlog/_fin/260911_account_pool_unification/020_phase2_shared_kernel.md similarity index 100% rename from devlog/_plan/260911_account_pool_unification/020_phase2_shared_kernel.md rename to devlog/_fin/260911_account_pool_unification/020_phase2_shared_kernel.md diff --git a/devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md b/devlog/_fin/260911_account_pool_unification/030_phase3_cache_affinity.md similarity index 100% rename from devlog/_plan/260911_account_pool_unification/030_phase3_cache_affinity.md rename to devlog/_fin/260911_account_pool_unification/030_phase3_cache_affinity.md diff --git a/devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md b/devlog/_fin/260911_account_pool_unification/040_phase4_key_pool_strategy.md similarity index 100% rename from devlog/_plan/260911_account_pool_unification/040_phase4_key_pool_strategy.md rename to devlog/_fin/260911_account_pool_unification/040_phase4_key_pool_strategy.md diff --git a/devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md b/devlog/_fin/260911_account_pool_unification/050_phase5_surface_consolidation.md similarity index 100% rename from devlog/_plan/260911_account_pool_unification/050_phase5_surface_consolidation.md rename to devlog/_fin/260911_account_pool_unification/050_phase5_surface_consolidation.md diff --git a/devlog/_plan/260911_account_pool_unification/assets/wp5b-pool-settings.png b/devlog/_fin/260911_account_pool_unification/assets/wp5b-pool-settings.png similarity index 100% rename from devlog/_plan/260911_account_pool_unification/assets/wp5b-pool-settings.png rename to devlog/_fin/260911_account_pool_unification/assets/wp5b-pool-settings.png diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md b/devlog/_fin/260911_catalog_presentation_and_effort_projection/000_plan.md similarity index 99% rename from devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md rename to devlog/_fin/260911_catalog_presentation_and_effort_projection/000_plan.md index 179bdb55d45..5deb2e392d5 100644 --- a/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md +++ b/devlog/_fin/260911_catalog_presentation_and_effort_projection/000_plan.md @@ -33,7 +33,7 @@ Evidence: `010_evidence.md`. Upstream issues: #4204 (efforts), #4213 (cards). | Non-goals | Proxying `wham/workspace-messages`. Changing image generation. Authoring card copy that upstream does not ship. Pushing to any remote. Clamping rungs other than `max`/`ultra` — the rest of the ladder keeps its current observed-runtime behaviour. | | Verifier | `bun test tests/codex-integration/codex-runtime.test.ts tests/codex-integration/catalog-go-exact-efforts.test.ts` — RUN, exit 0, 42 pass, and it reads `src/codex/runtime.ts` (`codex-runtime.test.ts:37` imports it; `:612` calls `effortClampAppliesToRuntime`). **That pair is sufficient for Phase 1 and NOT sufficient for Phase 2** — `catalog-go-exact-efforts.test.ts` pins construction, never the clamp. The gate that observes Phase 2 is `bun test tests/codex-integration/codex-catalog.test.ts tests/codex-integration/reserve-catalog.test.ts tests/clients/client-catalog-compatibility.test.ts` (see `030_test_impact.md` for the exact test names that must invert). NOT RUN at plan time — inverting them is B's work, so a pass today would prove nothing. Phase 3 has no gate until its test exists; that acceptance row is human review. | | Stop condition | ~~Plan-only~~ Superseded 2026-09-11 (second session): the user authorised the full PABCD cycle. This cycle ends at D with Phases 1, 2 and 4 built and verified. Phase 3 stays withdrawn (GAP-2). | -| Memory artifact | This unit directory: `devlog/_plan/260911_catalog_presentation_and_effort_projection/`. | +| Memory artifact | This unit directory: `devlog/_fin/260911_catalog_presentation_and_effort_projection/`. | | Expected terminal outcomes | Success: Phases 1, 2 and 4 land on `codex/260911-clamp-expiry` with their gates green. Resolved: GAP-1 (CLAMP-04 ships — see `050_revalidation.md`). Unresolved: GAP-2/GAP-3 in `040_open_gaps.md` are not closed by this cycle. Blocked: Phase 3 stays blocked on a live account-roster probe that only the user can authorise. | | Escalation condition | The account-roster probe needs a real ChatGPT token; main does not take it. CLAMP-04 changes the meaning of an active clamp and inverts diagnostic tests the plan currently promises to keep green — that contradiction goes to the user, not to B. Reserve deletion is no longer an escalation: CLAMP-05 decided it. | diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/010_evidence.md b/devlog/_fin/260911_catalog_presentation_and_effort_projection/010_evidence.md similarity index 100% rename from devlog/_plan/260911_catalog_presentation_and_effort_projection/010_evidence.md rename to devlog/_fin/260911_catalog_presentation_and_effort_projection/010_evidence.md diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md b/devlog/_fin/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md similarity index 100% rename from devlog/_plan/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md rename to devlog/_fin/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/030_test_impact.md b/devlog/_fin/260911_catalog_presentation_and_effort_projection/030_test_impact.md similarity index 100% rename from devlog/_plan/260911_catalog_presentation_and_effort_projection/030_test_impact.md rename to devlog/_fin/260911_catalog_presentation_and_effort_projection/030_test_impact.md diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md b/devlog/_fin/260911_catalog_presentation_and_effort_projection/040_open_gaps.md similarity index 100% rename from devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md rename to devlog/_fin/260911_catalog_presentation_and_effort_projection/040_open_gaps.md diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/050_revalidation.md b/devlog/_fin/260911_catalog_presentation_and_effort_projection/050_revalidation.md similarity index 100% rename from devlog/_plan/260911_catalog_presentation_and_effort_projection/050_revalidation.md rename to devlog/_fin/260911_catalog_presentation_and_effort_projection/050_revalidation.md diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/060_done.md b/devlog/_fin/260911_catalog_presentation_and_effort_projection/060_done.md similarity index 100% rename from devlog/_plan/260911_catalog_presentation_and_effort_projection/060_done.md rename to devlog/_fin/260911_catalog_presentation_and_effort_projection/060_done.md diff --git a/devlog/_plan/260911_deepseek_v41_transition/000_plan.md b/devlog/_fin/260911_deepseek_v41_transition/000_plan.md similarity index 98% rename from devlog/_plan/260911_deepseek_v41_transition/000_plan.md rename to devlog/_fin/260911_deepseek_v41_transition/000_plan.md index 9c2493b74aa..48f3d6b8b1a 100644 --- a/devlog/_plan/260911_deepseek_v41_transition/000_plan.md +++ b/devlog/_fin/260911_deepseek_v41_transition/000_plan.md @@ -14,7 +14,7 @@ DeepSeek가 2026-09-10에 V4.1-Flash를 내면서 V4 계열의 이름이 한 번 | Non-goals | 새 사용자 config 필드, 어댑터 와이어 동작 변경, main/preview 승격, 릴리스, 생성 메타데이터 수작업 편집 | | Verifier | `bun test` 영향 도메인, `bun run typecheck`, `bun run privacy:scan`, 머지 전 exact-head CI | | Stop condition | 두 PR과 이번 변경이 dev에 머지된 시점 | -| Memory artifact | `devlog/_plan/260911_deepseek_v41_transition/` | +| Memory artifact | `devlog/_fin/260911_deepseek_v41_transition/` | | Expected terminal outcomes | DONE = 머지 완료. BLOCKED = CI가 이 변경과 무관한 이유로 반복 실패하거나 머지 권한이 거부될 때 | | Escalation condition | 사용자가 머지를 명시 승인했다. main/preview 승격과 릴리스는 별도 승인 필요 | | Resource bounds | 쓰기 범위: `src/`, `tests/`, `docs-site/`, 이 플랜 유닛. 전체 스위트는 사용자 지시로 로컬에서 돌리지 않고 CI에 위임한다 | diff --git a/devlog/_plan/260911_deepseek_v41_transition/001_evidence.md b/devlog/_fin/260911_deepseek_v41_transition/001_evidence.md similarity index 100% rename from devlog/_plan/260911_deepseek_v41_transition/001_evidence.md rename to devlog/_fin/260911_deepseek_v41_transition/001_evidence.md diff --git a/devlog/_plan/260911_deepseek_v41_transition/002_inventory.md b/devlog/_fin/260911_deepseek_v41_transition/002_inventory.md similarity index 100% rename from devlog/_plan/260911_deepseek_v41_transition/002_inventory.md rename to devlog/_fin/260911_deepseek_v41_transition/002_inventory.md diff --git a/devlog/_plan/260911_deepseek_v41_transition/010_phase1_pr4258.md b/devlog/_fin/260911_deepseek_v41_transition/010_phase1_pr4258.md similarity index 100% rename from devlog/_plan/260911_deepseek_v41_transition/010_phase1_pr4258.md rename to devlog/_fin/260911_deepseek_v41_transition/010_phase1_pr4258.md diff --git a/devlog/_plan/260911_deepseek_v41_transition/020_phase2_v41_rollout.md b/devlog/_fin/260911_deepseek_v41_transition/020_phase2_v41_rollout.md similarity index 100% rename from devlog/_plan/260911_deepseek_v41_transition/020_phase2_v41_rollout.md rename to devlog/_fin/260911_deepseek_v41_transition/020_phase2_v41_rollout.md diff --git a/devlog/_plan/260911_deepseek_v41_transition/030_phase3_v4pro_removal.md b/devlog/_fin/260911_deepseek_v41_transition/030_phase3_v4pro_removal.md similarity index 100% rename from devlog/_plan/260911_deepseek_v41_transition/030_phase3_v4pro_removal.md rename to devlog/_fin/260911_deepseek_v41_transition/030_phase3_v4pro_removal.md diff --git a/devlog/_plan/260911_deepseek_v41_transition/040_phase4_merge.md b/devlog/_fin/260911_deepseek_v41_transition/040_phase4_merge.md similarity index 100% rename from devlog/_plan/260911_deepseek_v41_transition/040_phase4_merge.md rename to devlog/_fin/260911_deepseek_v41_transition/040_phase4_merge.md diff --git a/devlog/_plan/260911_devin_two_providers/001_plan.md b/devlog/_fin/260911_devin_two_providers/001_plan.md similarity index 100% rename from devlog/_plan/260911_devin_two_providers/001_plan.md rename to devlog/_fin/260911_devin_two_providers/001_plan.md diff --git a/devlog/_plan/260911_devin_two_providers/002_audit.md b/devlog/_fin/260911_devin_two_providers/002_audit.md similarity index 100% rename from devlog/_plan/260911_devin_two_providers/002_audit.md rename to devlog/_fin/260911_devin_two_providers/002_audit.md diff --git a/devlog/_plan/260911_devin_two_providers/003_live_evidence.md b/devlog/_fin/260911_devin_two_providers/003_live_evidence.md similarity index 100% rename from devlog/_plan/260911_devin_two_providers/003_live_evidence.md rename to devlog/_fin/260911_devin_two_providers/003_live_evidence.md diff --git a/devlog/_plan/260911_devin_two_providers/004_devin_cli_split.md b/devlog/_fin/260911_devin_two_providers/004_devin_cli_split.md similarity index 100% rename from devlog/_plan/260911_devin_two_providers/004_devin_cli_split.md rename to devlog/_fin/260911_devin_two_providers/004_devin_cli_split.md diff --git a/devlog/_plan/260911_hub_single_port/010_launchd_repair.md b/devlog/_fin/260911_hub_single_port/010_launchd_repair.md similarity index 100% rename from devlog/_plan/260911_hub_single_port/010_launchd_repair.md rename to devlog/_fin/260911_hub_single_port/010_launchd_repair.md diff --git a/devlog/_plan/260911_hub_single_port/020_loopback_companion.md b/devlog/_fin/260911_hub_single_port/020_loopback_companion.md similarity index 99% rename from devlog/_plan/260911_hub_single_port/020_loopback_companion.md rename to devlog/_fin/260911_hub_single_port/020_loopback_companion.md index 8ee9d64bf21..ab1b8190259 100644 --- a/devlog/_plan/260911_hub_single_port/020_loopback_companion.md +++ b/devlog/_fin/260911_hub_single_port/020_loopback_companion.md @@ -1,6 +1,6 @@ # 020 — PR2: same-port loopback companion, honest hub-gate messages -Unit: `devlog/_plan/260911_hub_single_port`. Stack position 2 of 4 (PR1 = launchd repair, +Unit: `devlog/_fin/260911_hub_single_port`. Stack position 2 of 4 (PR1 = launchd repair, PR3 = hub token UX, PR4 = docs/skill). Branch `codex/260911-l4-hub-loopback-companion`, based on `dev` = `babb76449` (`Merge pull request #4240 … codex/260911-l4-client-catalog`); it will be rebased onto PR1's branch. Issue: lidge-jun/opencodex#4236 (follow-ups 1 and 2, diff --git a/devlog/_plan/260911_hub_single_port/030_hub_local_clients.md b/devlog/_fin/260911_hub_single_port/030_hub_local_clients.md similarity index 99% rename from devlog/_plan/260911_hub_single_port/030_hub_local_clients.md rename to devlog/_fin/260911_hub_single_port/030_hub_local_clients.md index a797a24b044..c1200c4672a 100644 --- a/devlog/_plan/260911_hub_single_port/030_hub_local_clients.md +++ b/devlog/_fin/260911_hub_single_port/030_hub_local_clients.md @@ -1,6 +1,6 @@ # 030 — PR3: the hub's own local clients, two destination contracts -Unit: `devlog/_plan/260911_hub_single_port`. Stack position 3 of 4. Branch +Unit: `devlog/_fin/260911_hub_single_port`. Stack position 3 of 4. Branch `codex/260911-l4-hub-local-clients`, based on `codex/260911-l4-hub-loopback-companion` (= `dev` + PR1 launchd repair + PR2 loopback companion). That base gained a review fix (`0aca6afe4`, hub-gate conjunction + every all-zero hostname spelling) while this unit was being diff --git a/devlog/_plan/260911_hub_single_port/040_hub_token_ux.md b/devlog/_fin/260911_hub_single_port/040_hub_token_ux.md similarity index 99% rename from devlog/_plan/260911_hub_single_port/040_hub_token_ux.md rename to devlog/_fin/260911_hub_single_port/040_hub_token_ux.md index 59d04ab9fa3..5ed04581657 100644 --- a/devlog/_plan/260911_hub_single_port/040_hub_token_ux.md +++ b/devlog/_fin/260911_hub_single_port/040_hub_token_ux.md @@ -1,6 +1,6 @@ # 040 — PR4: data-plane token provisioning, `ocx hub invite`, the status hub block -Unit: `devlog/_plan/260911_hub_single_port`. Stack position 4 of 5 as actually built (PR1 launchd +Unit: `devlog/_fin/260911_hub_single_port`. Stack position 4 of 5 as actually built (PR1 launchd repair, PR2 same-port loopback companion, PR3 Claude/local-client destinations — written concurrently by another agent — this one, then the docs/skill PR). Branch `codex/260911-l4-hub-token-ux`, rebased onto `codex/260911-l4-hub-loopback-companion` = @@ -11,7 +11,7 @@ rebased tree, and the rebase was clean (no shared files). Issue: lidge-jun/openc **Base discrepancy, recorded because it affects review.** The assignment described the base as "dev + PR1 launchd fix + PR2 loopback companion". `git log babb76449..HEAD` on that branch shows only the six PR2 commits; `src/service.ts` is untouched by it, and no -`devlog/_plan/260911_hub_single_port/010_launchd_repair.md` exists. So PR1 is **not** in this +`devlog/_fin/260911_hub_single_port/010_launchd_repair.md` exists. So PR1 is **not** in this branch's ancestry, and everything below was written and verified against dev + PR2 only. The `src/service.ts` hunks here are all inside `assertNotAdminToken` / `assertServiceAuthEnvironment` / `writeServiceApiTokenFile`, which the PR1 description (macOS diff --git a/devlog/_plan/260911_hub_single_port/050_docs_skill.md b/devlog/_fin/260911_hub_single_port/050_docs_skill.md similarity index 99% rename from devlog/_plan/260911_hub_single_port/050_docs_skill.md rename to devlog/_fin/260911_hub_single_port/050_docs_skill.md index bfbce5e7925..55bb138c35d 100644 --- a/devlog/_plan/260911_hub_single_port/050_docs_skill.md +++ b/devlog/_fin/260911_hub_single_port/050_docs_skill.md @@ -1,6 +1,6 @@ # 050 — PR5: docs (en + ko), the `ocx` skill, and the help copy -Unit: `devlog/_plan/260911_hub_single_port`. Stack position 5 of 5. Branch +Unit: `devlog/_fin/260911_hub_single_port`. Stack position 5 of 5. Branch `codex/260911-l7-hub-docs-skill`, on `codex/260911-l4-hub-token-ux` = `b26eee311` (`docs(devlog): record the PR4 review round`), which carries PR1 (launchd repair), PR2 (loopback companion), PR3 (hub local clients) and PR4 (hub token UX). Issue: lidge-jun/opencodex#4236. The diff --git a/devlog/_plan/260911_hub_single_port/060_client_hub_state.md b/devlog/_fin/260911_hub_single_port/060_client_hub_state.md similarity index 99% rename from devlog/_plan/260911_hub_single_port/060_client_hub_state.md rename to devlog/_fin/260911_hub_single_port/060_client_hub_state.md index 6c4bf435535..ba2d2a6a953 100644 --- a/devlog/_plan/260911_hub_single_port/060_client_hub_state.md +++ b/devlog/_fin/260911_hub_single_port/060_client_hub_state.md @@ -1,6 +1,6 @@ # 060 — PR6: a connected client reports the hub's state, not its own -Unit: `devlog/_plan/260911_hub_single_port`. Stack position 6, branch +Unit: `devlog/_fin/260911_hub_single_port`. Stack position 6, branch `codex/260911-l4-client-hub-state`, based on `codex/260911-l4-hub-token-ux` = `8c277294c` (`test(service): drop the installLaunchd import the restack left unused`), so PR1 launchd repair, PR2 loopback companion, PR3 hub local clients and PR4 hub token UX are all in ancestry. Sibling of diff --git a/devlog/_plan/260911_l1_responses_core/000_packet.md b/devlog/_fin/260911_l1_responses_core/000_packet.md similarity index 98% rename from devlog/_plan/260911_l1_responses_core/000_packet.md rename to devlog/_fin/260911_l1_responses_core/000_packet.md index fec95cdedb1..8c1db50512e 100644 --- a/devlog/_plan/260911_l1_responses_core/000_packet.md +++ b/devlog/_fin/260911_l1_responses_core/000_packet.md @@ -1,6 +1,6 @@ # Dispatch packet — L1 (revision 5) -Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Round unit: `devlog/_fin/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. diff --git a/devlog/_plan/260911_l2_catalog_provider/000_packet.md b/devlog/_fin/260911_l2_catalog_provider/000_packet.md similarity index 97% rename from devlog/_plan/260911_l2_catalog_provider/000_packet.md rename to devlog/_fin/260911_l2_catalog_provider/000_packet.md index 529a5f5e33c..bb0b181d192 100644 --- a/devlog/_plan/260911_l2_catalog_provider/000_packet.md +++ b/devlog/_fin/260911_l2_catalog_provider/000_packet.md @@ -1,6 +1,6 @@ # Dispatch packet — L2 (revision 5) -Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Round unit: `devlog/_fin/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. diff --git a/devlog/_plan/260911_l2_catalog_provider/010_4201_quota_admission.md b/devlog/_fin/260911_l2_catalog_provider/010_4201_quota_admission.md similarity index 100% rename from devlog/_plan/260911_l2_catalog_provider/010_4201_quota_admission.md rename to devlog/_fin/260911_l2_catalog_provider/010_4201_quota_admission.md diff --git a/devlog/_plan/260911_l2_catalog_provider/020_flash_upstream_evidence.md b/devlog/_fin/260911_l2_catalog_provider/020_flash_upstream_evidence.md similarity index 100% rename from devlog/_plan/260911_l2_catalog_provider/020_flash_upstream_evidence.md rename to devlog/_fin/260911_l2_catalog_provider/020_flash_upstream_evidence.md diff --git a/devlog/_plan/260911_l3_account_pool/000_packet.md b/devlog/_fin/260911_l3_account_pool/000_packet.md similarity index 98% rename from devlog/_plan/260911_l3_account_pool/000_packet.md rename to devlog/_fin/260911_l3_account_pool/000_packet.md index 94123e6fe83..071aa7ae4ec 100644 --- a/devlog/_plan/260911_l3_account_pool/000_packet.md +++ b/devlog/_fin/260911_l3_account_pool/000_packet.md @@ -1,6 +1,6 @@ # Dispatch packet — L3 (revision 5) -Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Round unit: `devlog/_fin/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. diff --git a/devlog/_plan/260911_l3_account_pool/010_wp1_4126_warmup.md b/devlog/_fin/260911_l3_account_pool/010_wp1_4126_warmup.md similarity index 100% rename from devlog/_plan/260911_l3_account_pool/010_wp1_4126_warmup.md rename to devlog/_fin/260911_l3_account_pool/010_wp1_4126_warmup.md diff --git a/devlog/_plan/260911_l3_account_pool/020_wp2_4212_attribution.md b/devlog/_fin/260911_l3_account_pool/020_wp2_4212_attribution.md similarity index 100% rename from devlog/_plan/260911_l3_account_pool/020_wp2_4212_attribution.md rename to devlog/_fin/260911_l3_account_pool/020_wp2_4212_attribution.md diff --git a/devlog/_plan/260911_l3_account_pool/030_wp3_4211_plan_exclusion.md b/devlog/_fin/260911_l3_account_pool/030_wp3_4211_plan_exclusion.md similarity index 100% rename from devlog/_plan/260911_l3_account_pool/030_wp3_4211_plan_exclusion.md rename to devlog/_fin/260911_l3_account_pool/030_wp3_4211_plan_exclusion.md diff --git a/devlog/_plan/260911_l4_service_cli/000_packet.md b/devlog/_fin/260911_l4_service_cli/000_packet.md similarity index 98% rename from devlog/_plan/260911_l4_service_cli/000_packet.md rename to devlog/_fin/260911_l4_service_cli/000_packet.md index 86dc8351a2d..26f2f5305e0 100644 --- a/devlog/_plan/260911_l4_service_cli/000_packet.md +++ b/devlog/_fin/260911_l4_service_cli/000_packet.md @@ -1,6 +1,6 @@ # Dispatch packet — L4 (revision 5) -Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Round unit: `devlog/_fin/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. diff --git a/devlog/_plan/260911_l4_service_cli/010_wp1_pnpm_self_update.md b/devlog/_fin/260911_l4_service_cli/010_wp1_pnpm_self_update.md similarity index 100% rename from devlog/_plan/260911_l4_service_cli/010_wp1_pnpm_self_update.md rename to devlog/_fin/260911_l4_service_cli/010_wp1_pnpm_self_update.md diff --git a/devlog/_plan/260911_l4_service_cli/020_wp2_stop_refusal.md b/devlog/_fin/260911_l4_service_cli/020_wp2_stop_refusal.md similarity index 100% rename from devlog/_plan/260911_l4_service_cli/020_wp2_stop_refusal.md rename to devlog/_fin/260911_l4_service_cli/020_wp2_stop_refusal.md diff --git a/devlog/_plan/260911_l4_service_cli/030_wp3_client_catalog.md b/devlog/_fin/260911_l4_service_cli/030_wp3_client_catalog.md similarity index 100% rename from devlog/_plan/260911_l4_service_cli/030_wp3_client_catalog.md rename to devlog/_fin/260911_l4_service_cli/030_wp3_client_catalog.md diff --git a/devlog/_plan/260911_l5_integrations_io/000_packet.md b/devlog/_fin/260911_l5_integrations_io/000_packet.md similarity index 98% rename from devlog/_plan/260911_l5_integrations_io/000_packet.md rename to devlog/_fin/260911_l5_integrations_io/000_packet.md index 24f0fcd6ec4..b6adb970935 100644 --- a/devlog/_plan/260911_l5_integrations_io/000_packet.md +++ b/devlog/_fin/260911_l5_integrations_io/000_packet.md @@ -1,6 +1,6 @@ # Dispatch packet — L5 (revision 5) -Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Round unit: `devlog/_fin/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. diff --git a/devlog/_plan/260911_l6_streaming_tools/000_packet.md b/devlog/_fin/260911_l6_streaming_tools/000_packet.md similarity index 97% rename from devlog/_plan/260911_l6_streaming_tools/000_packet.md rename to devlog/_fin/260911_l6_streaming_tools/000_packet.md index 45a0a023b49..f5133493529 100644 --- a/devlog/_plan/260911_l6_streaming_tools/000_packet.md +++ b/devlog/_fin/260911_l6_streaming_tools/000_packet.md @@ -1,6 +1,6 @@ # Dispatch packet — L6 (revision 5) -Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Round unit: `devlog/_fin/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. diff --git a/devlog/_plan/260911_l6_streaming_tools/010_4191_ws_failure_classification.md b/devlog/_fin/260911_l6_streaming_tools/010_4191_ws_failure_classification.md similarity index 98% rename from devlog/_plan/260911_l6_streaming_tools/010_4191_ws_failure_classification.md rename to devlog/_fin/260911_l6_streaming_tools/010_4191_ws_failure_classification.md index f141f3ca56b..109352ebfbd 100644 --- a/devlog/_plan/260911_l6_streaming_tools/010_4191_ws_failure_classification.md +++ b/devlog/_fin/260911_l6_streaming_tools/010_4191_ws_failure_classification.md @@ -1,6 +1,6 @@ # 010 — #4191: classify the WebSocket failure instead of restating it -Unit: `devlog/_plan/260911_l6_streaming_tools`. Lane L6, work-phase 1. +Unit: `devlog/_fin/260911_l6_streaming_tools`. Lane L6, work-phase 1. Issue: [#4191](https://github.com/lidge-jun/opencodex/issues/4191). ## What the report actually contains diff --git a/devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md b/devlog/_fin/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md similarity index 98% rename from devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md rename to devlog/_fin/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md index f24b36fd9b0..572aac12c82 100644 --- a/devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md +++ b/devlog/_fin/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md @@ -1,6 +1,6 @@ # 020 — #4190: refuse vendor scaffolding on the qoder route -Unit: `devlog/_plan/260911_l6_streaming_tools`. Lane L6, work-phase 2. +Unit: `devlog/_fin/260911_l6_streaming_tools`. Lane L6, work-phase 2. Issue: [#4190](https://github.com/lidge-jun/opencodex/issues/4190). ## The contract that was being broken diff --git a/devlog/_plan/260911_l7_docs/000_packet.md b/devlog/_fin/260911_l7_docs/000_packet.md similarity index 97% rename from devlog/_plan/260911_l7_docs/000_packet.md rename to devlog/_fin/260911_l7_docs/000_packet.md index a7e38e9f18a..af9d4981396 100644 --- a/devlog/_plan/260911_l7_docs/000_packet.md +++ b/devlog/_fin/260911_l7_docs/000_packet.md @@ -1,6 +1,6 @@ # Dispatch packet — L7 (revision 5) -Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Round unit: `devlog/_fin/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. diff --git a/devlog/_plan/260911_l7_docs/010_roadmap.md b/devlog/_fin/260911_l7_docs/010_roadmap.md similarity index 100% rename from devlog/_plan/260911_l7_docs/010_roadmap.md rename to devlog/_fin/260911_l7_docs/010_roadmap.md diff --git a/devlog/_plan/260911_l7_docs/020_4215_wording.md b/devlog/_fin/260911_l7_docs/020_4215_wording.md similarity index 100% rename from devlog/_plan/260911_l7_docs/020_4215_wording.md rename to devlog/_fin/260911_l7_docs/020_4215_wording.md diff --git a/devlog/_plan/260911_l7_docs/030_4200_recipe.md b/devlog/_fin/260911_l7_docs/030_4200_recipe.md similarity index 100% rename from devlog/_plan/260911_l7_docs/030_4200_recipe.md rename to devlog/_fin/260911_l7_docs/030_4200_recipe.md diff --git a/devlog/_plan/260911_l7_docs/040_4215_delivery.md b/devlog/_fin/260911_l7_docs/040_4215_delivery.md similarity index 100% rename from devlog/_plan/260911_l7_docs/040_4215_delivery.md rename to devlog/_fin/260911_l7_docs/040_4215_delivery.md diff --git a/devlog/_plan/260911_l7_docs/050_4200_delivery.md b/devlog/_fin/260911_l7_docs/050_4200_delivery.md similarity index 100% rename from devlog/_plan/260911_l7_docs/050_4200_delivery.md rename to devlog/_fin/260911_l7_docs/050_4200_delivery.md diff --git a/devlog/_plan/260911_lane_dispatch_round/000_plan.md b/devlog/_fin/260911_lane_dispatch_round/000_plan.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/000_plan.md rename to devlog/_fin/260911_lane_dispatch_round/000_plan.md diff --git a/devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md b/devlog/_fin/260911_lane_dispatch_round/010_lane_partition.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/010_lane_partition.md rename to devlog/_fin/260911_lane_dispatch_round/010_lane_partition.md diff --git a/devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md b/devlog/_fin/260911_lane_dispatch_round/020_lane_packets.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/020_lane_packets.md rename to devlog/_fin/260911_lane_dispatch_round/020_lane_packets.md diff --git a/devlog/_plan/260911_lane_dispatch_round/030_audit_round1.md b/devlog/_fin/260911_lane_dispatch_round/030_audit_round1.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/030_audit_round1.md rename to devlog/_fin/260911_lane_dispatch_round/030_audit_round1.md diff --git a/devlog/_plan/260911_lane_dispatch_round/040_audit_round2.md b/devlog/_fin/260911_lane_dispatch_round/040_audit_round2.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/040_audit_round2.md rename to devlog/_fin/260911_lane_dispatch_round/040_audit_round2.md diff --git a/devlog/_plan/260911_lane_dispatch_round/050_audit_round3.md b/devlog/_fin/260911_lane_dispatch_round/050_audit_round3.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/050_audit_round3.md rename to devlog/_fin/260911_lane_dispatch_round/050_audit_round3.md diff --git a/devlog/_plan/260911_lane_dispatch_round/060_ledger.md b/devlog/_fin/260911_lane_dispatch_round/060_ledger.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/060_ledger.md rename to devlog/_fin/260911_lane_dispatch_round/060_ledger.md diff --git a/devlog/_plan/260911_lane_dispatch_round/070_dispatch_handoff.md b/devlog/_fin/260911_lane_dispatch_round/070_dispatch_handoff.md similarity index 92% rename from devlog/_plan/260911_lane_dispatch_round/070_dispatch_handoff.md rename to devlog/_fin/260911_lane_dispatch_round/070_dispatch_handoff.md index 402c4fb6ce6..cb79da61c0b 100644 --- a/devlog/_plan/260911_lane_dispatch_round/070_dispatch_handoff.md +++ b/devlog/_fin/260911_lane_dispatch_round/070_dispatch_handoff.md @@ -12,7 +12,7 @@ Paste the matching block into a new thread whose working directory is the lane w You are lane L1 of the 260911 dispatch round. Your worktree is ~/.codex/worktrees/260911-l1/opencodex, already on branch codex/260911-l1-responses-core, cut from origin/dev 6d3ad12e3. -Read devlog/_plan/260911_l1_responses_core/000_packet.md first and follow it exactly. It is your contract: +Read devlog/_fin/260911_l1_responses_core/000_packet.md first and follow it exactly. It is your contract: owned paths, the decisions already made for you, the rules, and the report format. Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4172 then #4176. @@ -31,7 +31,7 @@ is green, report and stop. You are lane L2 of the 260911 dispatch round. Your worktree is ~/.codex/worktrees/260911-l2/opencodex, already on branch codex/260911-l2-catalog-provider, cut from origin/dev 6d3ad12e3. -Read devlog/_plan/260911_l2_catalog_provider/000_packet.md first and follow it exactly. It is your contract: +Read devlog/_fin/260911_l2_catalog_provider/000_packet.md first and follow it exactly. It is your contract: owned paths, the decisions already made for you, the rules, and the report format. Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4201. @@ -50,7 +50,7 @@ is green, report and stop. You are lane L3 of the 260911 dispatch round. Your worktree is ~/.codex/worktrees/260911-l3/opencodex, already on branch codex/260911-l3-account-pool, cut from origin/dev 6d3ad12e3. -Read devlog/_plan/260911_l3_account_pool/000_packet.md first and follow it exactly. It is your contract: +Read devlog/_fin/260911_l3_account_pool/000_packet.md first and follow it exactly. It is your contract: owned paths, the decisions already made for you, the rules, and the report format. Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4126 then #4212 then #4211. @@ -69,7 +69,7 @@ is green, report and stop. You are lane L4 of the 260911 dispatch round. Your worktree is ~/.codex/worktrees/260911-l4/opencodex, already on branch codex/260911-l4-service-cli, cut from origin/dev 6d3ad12e3. -Read devlog/_plan/260911_l4_service_cli/000_packet.md first and follow it exactly. It is your contract: +Read devlog/_fin/260911_l4_service_cli/000_packet.md first and follow it exactly. It is your contract: owned paths, the decisions already made for you, the rules, and the report format. Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4202 then #4169 then #4204 then #4207. @@ -88,7 +88,7 @@ is green, report and stop. You are lane L5 of the 260911 dispatch round. Your worktree is ~/.codex/worktrees/260911-l5/opencodex, already on branch codex/260911-l5-integrations-io, cut from origin/dev 6d3ad12e3. -Read devlog/_plan/260911_l5_integrations_io/000_packet.md first and follow it exactly. It is your contract: +Read devlog/_fin/260911_l5_integrations_io/000_packet.md first and follow it exactly. It is your contract: owned paths, the decisions already made for you, the rules, and the report format. Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4197 then #4214. @@ -107,7 +107,7 @@ is green, report and stop. You are lane L6 of the 260911 dispatch round. Your worktree is ~/.codex/worktrees/260911-l6/opencodex, already on branch codex/260911-l6-streaming-tools, cut from origin/dev 6d3ad12e3. -Read devlog/_plan/260911_l6_streaming_tools/000_packet.md first and follow it exactly. It is your contract: +Read devlog/_fin/260911_l6_streaming_tools/000_packet.md first and follow it exactly. It is your contract: owned paths, the decisions already made for you, the rules, and the report format. Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4191 then #4190. @@ -126,7 +126,7 @@ is green, report and stop. You are lane L7 of the 260911 dispatch round. Your worktree is ~/.codex/worktrees/260911-l7/opencodex, already on branch codex/260911-l7-docs, cut from origin/dev 6d3ad12e3. -Read devlog/_plan/260911_l7_docs/000_packet.md first and follow it exactly. It is your contract: +Read devlog/_fin/260911_l7_docs/000_packet.md first and follow it exactly. It is your contract: owned paths, the decisions already made for you, the rules, and the report format. Run $codexclaw:cxc-loop as HOTL for your stack, in this order: #4215 then #4200. diff --git a/devlog/_plan/260911_lane_dispatch_round/080_wp2_publish_plan.md b/devlog/_fin/260911_lane_dispatch_round/080_wp2_publish_plan.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/080_wp2_publish_plan.md rename to devlog/_fin/260911_lane_dispatch_round/080_wp2_publish_plan.md diff --git a/devlog/_plan/260911_lane_dispatch_round/090_wp2_execution.md b/devlog/_fin/260911_lane_dispatch_round/090_wp2_execution.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/090_wp2_execution.md rename to devlog/_fin/260911_lane_dispatch_round/090_wp2_execution.md diff --git a/devlog/_plan/260911_lane_dispatch_round/100_wp3_plan.md b/devlog/_fin/260911_lane_dispatch_round/100_wp3_plan.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/100_wp3_plan.md rename to devlog/_fin/260911_lane_dispatch_round/100_wp3_plan.md diff --git a/devlog/_plan/260911_lane_dispatch_round/110_tracking_refresh.md b/devlog/_fin/260911_lane_dispatch_round/110_tracking_refresh.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/110_tracking_refresh.md rename to devlog/_fin/260911_lane_dispatch_round/110_tracking_refresh.md diff --git a/devlog/_plan/260911_lane_dispatch_round/120_wp4_plan.md b/devlog/_fin/260911_lane_dispatch_round/120_wp4_plan.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/120_wp4_plan.md rename to devlog/_fin/260911_lane_dispatch_round/120_wp4_plan.md diff --git a/devlog/_plan/260911_lane_dispatch_round/130_wp4_feasibility.md b/devlog/_fin/260911_lane_dispatch_round/130_wp4_feasibility.md similarity index 100% rename from devlog/_plan/260911_lane_dispatch_round/130_wp4_feasibility.md rename to devlog/_fin/260911_lane_dispatch_round/130_wp4_feasibility.md diff --git a/devlog/_plan/260911_ocx_login_codex_routing/000_plan.md b/devlog/_fin/260911_ocx_login_codex_routing/000_plan.md similarity index 100% rename from devlog/_plan/260911_ocx_login_codex_routing/000_plan.md rename to devlog/_fin/260911_ocx_login_codex_routing/000_plan.md diff --git a/devlog/_plan/260911_ocx_login_codex_routing/010_audit_round1.md b/devlog/_fin/260911_ocx_login_codex_routing/010_audit_round1.md similarity index 100% rename from devlog/_plan/260911_ocx_login_codex_routing/010_audit_round1.md rename to devlog/_fin/260911_ocx_login_codex_routing/010_audit_round1.md diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md b/devlog/_fin/260911_opencode_go_free_stabilization/000_plan.md similarity index 98% rename from devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md rename to devlog/_fin/260911_opencode_go_free_stabilization/000_plan.md index 55d56096a1c..001ccd47c6d 100644 --- a/devlog/_plan/260911_opencode_go_free_stabilization/000_plan.md +++ b/devlog/_fin/260911_opencode_go_free_stabilization/000_plan.md @@ -14,7 +14,7 @@ OpenCode Zen 게이트웨이(go, zen, free)로 붙는 세 프리셋은 정적 `m | Non-goals | `src/providers/command-code-efforts.ts`(열린 PR #4258 소유), 어댑터 와이어 동작 변경, 새 사용자 config 필드, 라이브 업스트림 프로브가 필요한 주장, 무키 free 티어 정책 변경 | | Verifier | `bun test tests/providers/provider-registry-parity.test.ts`, `bun test tests/providers/opencode-go-deepseek.test.ts`, `bun test tests/adapters/openai/openai-chat-hardening.test.ts`, `bun run typecheck`. 신설 가드는 수정 전 실패를 먼저 확인한다 | | Stop condition | PR이 dev를 base로 열리고 템플릿 3개 섹션이 채워진 시점 | -| Memory artifact | `devlog/_plan/260911_opencode_go_free_stabilization/` | +| Memory artifact | `devlog/_fin/260911_opencode_go_free_stabilization/` | | Expected terminal outcomes | DONE = PR 게시 + 모든 검증 명령 green. BLOCKED = 업스트림 사실 확인이 필요해 근거 없이 시드할 수 없는 항목이 남을 때 | | Escalation condition | push 권한은 사용자가 이미 준 PR 게시로 한정한다. 머지·릴리스는 별도 승인. 라이브 프로브가 필요한 주장은 시드하지 않고 보고한다 | | Resource bounds | 도구: repo 읽기/쓰기, gh 읽기 + PR 생성, grok-4.6 서브에이전트. 쓰기 범위: `src/providers`, `src/types`, `tests/providers`, `docs-site`, 이 플랜 유닛. 벽시계: 사용자 세션 내 | diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/001_issue_triage.md b/devlog/_fin/260911_opencode_go_free_stabilization/001_issue_triage.md similarity index 100% rename from devlog/_plan/260911_opencode_go_free_stabilization/001_issue_triage.md rename to devlog/_fin/260911_opencode_go_free_stabilization/001_issue_triage.md diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/002_cross_proxy_survey.md b/devlog/_fin/260911_opencode_go_free_stabilization/002_cross_proxy_survey.md similarity index 100% rename from devlog/_plan/260911_opencode_go_free_stabilization/002_cross_proxy_survey.md rename to devlog/_fin/260911_opencode_go_free_stabilization/002_cross_proxy_survey.md diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/003_registry_gap_inventory.md b/devlog/_fin/260911_opencode_go_free_stabilization/003_registry_gap_inventory.md similarity index 100% rename from devlog/_plan/260911_opencode_go_free_stabilization/003_registry_gap_inventory.md rename to devlog/_fin/260911_opencode_go_free_stabilization/003_registry_gap_inventory.md diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/004_audit_rounds.md b/devlog/_fin/260911_opencode_go_free_stabilization/004_audit_rounds.md similarity index 100% rename from devlog/_plan/260911_opencode_go_free_stabilization/004_audit_rounds.md rename to devlog/_fin/260911_opencode_go_free_stabilization/004_audit_rounds.md diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md b/devlog/_fin/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md similarity index 100% rename from devlog/_plan/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md rename to devlog/_fin/260911_opencode_go_free_stabilization/010_phase1_preset_structured_output.md diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md b/devlog/_fin/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md similarity index 100% rename from devlog/_plan/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md rename to devlog/_fin/260911_opencode_go_free_stabilization/020_phase2_preset_consistency_guard.md diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md b/devlog/_fin/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md similarity index 100% rename from devlog/_plan/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md rename to devlog/_fin/260911_opencode_go_free_stabilization/030_phase3_docs_and_pr.md diff --git a/devlog/_plan/260911_opencode_go_free_stabilization/031_wp4_outcome.md b/devlog/_fin/260911_opencode_go_free_stabilization/031_wp4_outcome.md similarity index 100% rename from devlog/_plan/260911_opencode_go_free_stabilization/031_wp4_outcome.md rename to devlog/_fin/260911_opencode_go_free_stabilization/031_wp4_outcome.md diff --git a/devlog/_plan/260911_r2_merge_train/000_plan.md b/devlog/_fin/260911_r2_merge_train/000_plan.md similarity index 100% rename from devlog/_plan/260911_r2_merge_train/000_plan.md rename to devlog/_fin/260911_r2_merge_train/000_plan.md diff --git a/devlog/_plan/260911_r2_merge_train/010_phase1_pr4244.md b/devlog/_fin/260911_r2_merge_train/010_phase1_pr4244.md similarity index 100% rename from devlog/_plan/260911_r2_merge_train/010_phase1_pr4244.md rename to devlog/_fin/260911_r2_merge_train/010_phase1_pr4244.md diff --git a/devlog/_plan/260911_r2_merge_train/020_phase2_pr4248.md b/devlog/_fin/260911_r2_merge_train/020_phase2_pr4248.md similarity index 100% rename from devlog/_plan/260911_r2_merge_train/020_phase2_pr4248.md rename to devlog/_fin/260911_r2_merge_train/020_phase2_pr4248.md diff --git a/devlog/_plan/260911_r2_merge_train/030_phase3_pr4246.md b/devlog/_fin/260911_r2_merge_train/030_phase3_pr4246.md similarity index 100% rename from devlog/_plan/260911_r2_merge_train/030_phase3_pr4246.md rename to devlog/_fin/260911_r2_merge_train/030_phase3_pr4246.md diff --git a/devlog/_plan/260911_r2_merge_train/040_phase4_pr4247.md b/devlog/_fin/260911_r2_merge_train/040_phase4_pr4247.md similarity index 100% rename from devlog/_plan/260911_r2_merge_train/040_phase4_pr4247.md rename to devlog/_fin/260911_r2_merge_train/040_phase4_pr4247.md diff --git a/devlog/_plan/260911_ws_commit_boundary/000_plan.md b/devlog/_fin/260911_ws_commit_boundary/000_plan.md similarity index 100% rename from devlog/_plan/260911_ws_commit_boundary/000_plan.md rename to devlog/_fin/260911_ws_commit_boundary/000_plan.md diff --git a/devlog/_plan/260911_ws_commit_boundary/010_journey_evaluation.md b/devlog/_fin/260911_ws_commit_boundary/010_journey_evaluation.md similarity index 100% rename from devlog/_plan/260911_ws_commit_boundary/010_journey_evaluation.md rename to devlog/_fin/260911_ws_commit_boundary/010_journey_evaluation.md diff --git a/devlog/_plan/260911_ws_commit_boundary/020_design_record.md b/devlog/_fin/260911_ws_commit_boundary/020_design_record.md similarity index 100% rename from devlog/_plan/260911_ws_commit_boundary/020_design_record.md rename to devlog/_fin/260911_ws_commit_boundary/020_design_record.md diff --git a/devlog/_plan/260911_ws_commit_boundary/025_audit_round1.md b/devlog/_fin/260911_ws_commit_boundary/025_audit_round1.md similarity index 100% rename from devlog/_plan/260911_ws_commit_boundary/025_audit_round1.md rename to devlog/_fin/260911_ws_commit_boundary/025_audit_round1.md diff --git a/devlog/_plan/260911_ws_commit_boundary/030_wp2_plan.md b/devlog/_fin/260911_ws_commit_boundary/030_wp2_plan.md similarity index 100% rename from devlog/_plan/260911_ws_commit_boundary/030_wp2_plan.md rename to devlog/_fin/260911_ws_commit_boundary/030_wp2_plan.md diff --git a/devlog/_plan/260911_ws_commit_boundary/040_wp3_plan.md b/devlog/_fin/260911_ws_commit_boundary/040_wp3_plan.md similarity index 100% rename from devlog/_plan/260911_ws_commit_boundary/040_wp3_plan.md rename to devlog/_fin/260911_ws_commit_boundary/040_wp3_plan.md diff --git a/devlog/_plan/260911_ws_commit_boundary/045_wp4_plan.md b/devlog/_fin/260911_ws_commit_boundary/045_wp4_plan.md similarity index 100% rename from devlog/_plan/260911_ws_commit_boundary/045_wp4_plan.md rename to devlog/_fin/260911_ws_commit_boundary/045_wp4_plan.md diff --git a/devlog/_plan/260911_ws_commit_boundary/050_review.md b/devlog/_fin/260911_ws_commit_boundary/050_review.md similarity index 100% rename from devlog/_plan/260911_ws_commit_boundary/050_review.md rename to devlog/_fin/260911_ws_commit_boundary/050_review.md diff --git a/devlog/_plan/260912_audio_apis_stack/000_plan.md b/devlog/_fin/260912_audio_apis_stack/000_plan.md similarity index 100% rename from devlog/_plan/260912_audio_apis_stack/000_plan.md rename to devlog/_fin/260912_audio_apis_stack/000_plan.md diff --git a/devlog/_plan/260912_audio_apis_stack/002_review_disposition.md b/devlog/_fin/260912_audio_apis_stack/002_review_disposition.md similarity index 100% rename from devlog/_plan/260912_audio_apis_stack/002_review_disposition.md rename to devlog/_fin/260912_audio_apis_stack/002_review_disposition.md diff --git a/devlog/_plan/260912_audio_apis_stack/010_transcription.md b/devlog/_fin/260912_audio_apis_stack/010_transcription.md similarity index 100% rename from devlog/_plan/260912_audio_apis_stack/010_transcription.md rename to devlog/_fin/260912_audio_apis_stack/010_transcription.md diff --git a/devlog/_plan/260912_audio_apis_stack/011_transcription_checks.md b/devlog/_fin/260912_audio_apis_stack/011_transcription_checks.md similarity index 100% rename from devlog/_plan/260912_audio_apis_stack/011_transcription_checks.md rename to devlog/_fin/260912_audio_apis_stack/011_transcription_checks.md diff --git a/devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md b/devlog/_fin/260912_audio_apis_stack/020_streaming_voice.md similarity index 100% rename from devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md rename to devlog/_fin/260912_audio_apis_stack/020_streaming_voice.md diff --git a/devlog/_plan/260912_audio_apis_stack/021_streaming_checks.md b/devlog/_fin/260912_audio_apis_stack/021_streaming_checks.md similarity index 100% rename from devlog/_plan/260912_audio_apis_stack/021_streaming_checks.md rename to devlog/_fin/260912_audio_apis_stack/021_streaming_checks.md diff --git a/devlog/_plan/260912_audio_apis_stack/030_connections.md b/devlog/_fin/260912_audio_apis_stack/030_connections.md similarity index 100% rename from devlog/_plan/260912_audio_apis_stack/030_connections.md rename to devlog/_fin/260912_audio_apis_stack/030_connections.md diff --git a/devlog/_plan/260912_audio_apis_stack/031_connections_checks.md b/devlog/_fin/260912_audio_apis_stack/031_connections_checks.md similarity index 100% rename from devlog/_plan/260912_audio_apis_stack/031_connections_checks.md rename to devlog/_fin/260912_audio_apis_stack/031_connections_checks.md diff --git a/devlog/_plan/260912_audio_apis_stack/screenshots/audio-1440-light.png b/devlog/_fin/260912_audio_apis_stack/screenshots/audio-1440-light.png similarity index 100% rename from devlog/_plan/260912_audio_apis_stack/screenshots/audio-1440-light.png rename to devlog/_fin/260912_audio_apis_stack/screenshots/audio-1440-light.png diff --git a/devlog/_plan/260912_audio_apis_stack/screenshots/audio-320-ko.png b/devlog/_fin/260912_audio_apis_stack/screenshots/audio-320-ko.png similarity index 100% rename from devlog/_plan/260912_audio_apis_stack/screenshots/audio-320-ko.png rename to devlog/_fin/260912_audio_apis_stack/screenshots/audio-320-ko.png diff --git a/devlog/_plan/260912_audio_apis_stack/screenshots/audio-390-light.png b/devlog/_fin/260912_audio_apis_stack/screenshots/audio-390-light.png similarity index 100% rename from devlog/_plan/260912_audio_apis_stack/screenshots/audio-390-light.png rename to devlog/_fin/260912_audio_apis_stack/screenshots/audio-390-light.png diff --git a/devlog/_plan/260912_audio_apis_stack/screenshots/transcription-layer-baseline.png b/devlog/_fin/260912_audio_apis_stack/screenshots/transcription-layer-baseline.png similarity index 100% rename from devlog/_plan/260912_audio_apis_stack/screenshots/transcription-layer-baseline.png rename to devlog/_fin/260912_audio_apis_stack/screenshots/transcription-layer-baseline.png diff --git a/devlog/_plan/260912_beginner_pdf/000_plan.md b/devlog/_fin/260912_beginner_pdf/000_plan.md similarity index 100% rename from devlog/_plan/260912_beginner_pdf/000_plan.md rename to devlog/_fin/260912_beginner_pdf/000_plan.md diff --git a/devlog/_plan/260912_cache_lane/000_plan.md b/devlog/_fin/260912_cache_lane/000_plan.md similarity index 100% rename from devlog/_plan/260912_cache_lane/000_plan.md rename to devlog/_fin/260912_cache_lane/000_plan.md diff --git a/devlog/_plan/260912_cache_lane/010_claim.md b/devlog/_fin/260912_cache_lane/010_claim.md similarity index 100% rename from devlog/_plan/260912_cache_lane/010_claim.md rename to devlog/_fin/260912_cache_lane/010_claim.md diff --git a/devlog/_plan/260912_cache_lane/020_affinity.md b/devlog/_fin/260912_cache_lane/020_affinity.md similarity index 100% rename from devlog/_plan/260912_cache_lane/020_affinity.md rename to devlog/_fin/260912_cache_lane/020_affinity.md diff --git a/devlog/_plan/260912_cache_lane/025_affinity_native.md b/devlog/_fin/260912_cache_lane/025_affinity_native.md similarity index 100% rename from devlog/_plan/260912_cache_lane/025_affinity_native.md rename to devlog/_fin/260912_cache_lane/025_affinity_native.md diff --git a/devlog/_plan/260912_cache_lane/026_affinity_adapt.md b/devlog/_fin/260912_cache_lane/026_affinity_adapt.md similarity index 100% rename from devlog/_plan/260912_cache_lane/026_affinity_adapt.md rename to devlog/_fin/260912_cache_lane/026_affinity_adapt.md diff --git a/devlog/_plan/260912_cache_lane/027_affinity_slot.md b/devlog/_fin/260912_cache_lane/027_affinity_slot.md similarity index 100% rename from devlog/_plan/260912_cache_lane/027_affinity_slot.md rename to devlog/_fin/260912_cache_lane/027_affinity_slot.md diff --git a/devlog/_plan/260912_cache_lane/030_prefix.md b/devlog/_fin/260912_cache_lane/030_prefix.md similarity index 100% rename from devlog/_plan/260912_cache_lane/030_prefix.md rename to devlog/_fin/260912_cache_lane/030_prefix.md diff --git a/devlog/_plan/260912_cache_lane/035_prefix_adapt.md b/devlog/_fin/260912_cache_lane/035_prefix_adapt.md similarity index 100% rename from devlog/_plan/260912_cache_lane/035_prefix_adapt.md rename to devlog/_fin/260912_cache_lane/035_prefix_adapt.md diff --git a/devlog/_plan/260912_cache_lane/036_prefix_linear.md b/devlog/_fin/260912_cache_lane/036_prefix_linear.md similarity index 100% rename from devlog/_plan/260912_cache_lane/036_prefix_linear.md rename to devlog/_fin/260912_cache_lane/036_prefix_linear.md diff --git a/devlog/_plan/260912_cache_lane/037_prefix_slot.md b/devlog/_fin/260912_cache_lane/037_prefix_slot.md similarity index 100% rename from devlog/_plan/260912_cache_lane/037_prefix_slot.md rename to devlog/_fin/260912_cache_lane/037_prefix_slot.md diff --git a/devlog/_plan/260912_cache_lane/040_hermes.md b/devlog/_fin/260912_cache_lane/040_hermes.md similarity index 100% rename from devlog/_plan/260912_cache_lane/040_hermes.md rename to devlog/_fin/260912_cache_lane/040_hermes.md diff --git a/devlog/_plan/260912_cache_lane/041_hermes_ci_refresh.md b/devlog/_fin/260912_cache_lane/041_hermes_ci_refresh.md similarity index 100% rename from devlog/_plan/260912_cache_lane/041_hermes_ci_refresh.md rename to devlog/_fin/260912_cache_lane/041_hermes_ci_refresh.md diff --git a/devlog/_plan/260912_cache_lane/050_verify.md b/devlog/_fin/260912_cache_lane/050_verify.md similarity index 100% rename from devlog/_plan/260912_cache_lane/050_verify.md rename to devlog/_fin/260912_cache_lane/050_verify.md diff --git a/devlog/_plan/260912_cache_lane/060_handoff.md b/devlog/_fin/260912_cache_lane/060_handoff.md similarity index 100% rename from devlog/_plan/260912_cache_lane/060_handoff.md rename to devlog/_fin/260912_cache_lane/060_handoff.md diff --git a/devlog/_plan/260912_campaign_ci_fixtures/000_plan.md b/devlog/_fin/260912_campaign_ci_fixtures/000_plan.md similarity index 100% rename from devlog/_plan/260912_campaign_ci_fixtures/000_plan.md rename to devlog/_fin/260912_campaign_ci_fixtures/000_plan.md diff --git a/devlog/_plan/260912_catalog_lane_readiness/000_plan.md b/devlog/_fin/260912_catalog_lane_readiness/000_plan.md similarity index 100% rename from devlog/_plan/260912_catalog_lane_readiness/000_plan.md rename to devlog/_fin/260912_catalog_lane_readiness/000_plan.md diff --git a/devlog/_plan/260912_catalog_lane_readiness/010_roadmap.md b/devlog/_fin/260912_catalog_lane_readiness/010_roadmap.md similarity index 100% rename from devlog/_plan/260912_catalog_lane_readiness/010_roadmap.md rename to devlog/_fin/260912_catalog_lane_readiness/010_roadmap.md diff --git a/devlog/_plan/260912_catalog_lane_readiness/011_design_reflection.md b/devlog/_fin/260912_catalog_lane_readiness/011_design_reflection.md similarity index 100% rename from devlog/_plan/260912_catalog_lane_readiness/011_design_reflection.md rename to devlog/_fin/260912_catalog_lane_readiness/011_design_reflection.md diff --git a/devlog/_plan/260912_catalog_lane_readiness/020_review.md b/devlog/_fin/260912_catalog_lane_readiness/020_review.md similarity index 100% rename from devlog/_plan/260912_catalog_lane_readiness/020_review.md rename to devlog/_fin/260912_catalog_lane_readiness/020_review.md diff --git a/devlog/_plan/260912_catalog_lane_readiness/022_keyboard_repair.md b/devlog/_fin/260912_catalog_lane_readiness/022_keyboard_repair.md similarity index 100% rename from devlog/_plan/260912_catalog_lane_readiness/022_keyboard_repair.md rename to devlog/_fin/260912_catalog_lane_readiness/022_keyboard_repair.md diff --git a/devlog/_plan/260912_catalog_lane_readiness/030_evidence.md b/devlog/_fin/260912_catalog_lane_readiness/030_evidence.md similarity index 100% rename from devlog/_plan/260912_catalog_lane_readiness/030_evidence.md rename to devlog/_fin/260912_catalog_lane_readiness/030_evidence.md diff --git a/devlog/_plan/260912_cline_client/000_plan.md b/devlog/_fin/260912_cline_client/000_plan.md similarity index 100% rename from devlog/_plan/260912_cline_client/000_plan.md rename to devlog/_fin/260912_cline_client/000_plan.md diff --git a/devlog/_plan/260912_cline_client/001_contract.md b/devlog/_fin/260912_cline_client/001_contract.md similarity index 100% rename from devlog/_plan/260912_cline_client/001_contract.md rename to devlog/_fin/260912_cline_client/001_contract.md diff --git a/devlog/_plan/260912_cline_client/009_roadmap_result.md b/devlog/_fin/260912_cline_client/009_roadmap_result.md similarity index 100% rename from devlog/_plan/260912_cline_client/009_roadmap_result.md rename to devlog/_fin/260912_cline_client/009_roadmap_result.md diff --git a/devlog/_plan/260912_cline_client/010_contract.md b/devlog/_fin/260912_cline_client/010_contract.md similarity index 100% rename from devlog/_plan/260912_cline_client/010_contract.md rename to devlog/_fin/260912_cline_client/010_contract.md diff --git a/devlog/_plan/260912_cline_client/019_contract_result.md b/devlog/_fin/260912_cline_client/019_contract_result.md similarity index 100% rename from devlog/_plan/260912_cline_client/019_contract_result.md rename to devlog/_fin/260912_cline_client/019_contract_result.md diff --git a/devlog/_plan/260912_cline_client/020_surfaces.md b/devlog/_fin/260912_cline_client/020_surfaces.md similarity index 100% rename from devlog/_plan/260912_cline_client/020_surfaces.md rename to devlog/_fin/260912_cline_client/020_surfaces.md diff --git a/devlog/_plan/260912_cline_client/029_surfaces_result.md b/devlog/_fin/260912_cline_client/029_surfaces_result.md similarity index 100% rename from devlog/_plan/260912_cline_client/029_surfaces_result.md rename to devlog/_fin/260912_cline_client/029_surfaces_result.md diff --git a/devlog/_plan/260912_cline_client/030_verification.md b/devlog/_fin/260912_cline_client/030_verification.md similarity index 100% rename from devlog/_plan/260912_cline_client/030_verification.md rename to devlog/_fin/260912_cline_client/030_verification.md diff --git a/devlog/_plan/260912_cline_client/039_gui_evidence.md b/devlog/_fin/260912_cline_client/039_gui_evidence.md similarity index 100% rename from devlog/_plan/260912_cline_client/039_gui_evidence.md rename to devlog/_fin/260912_cline_client/039_gui_evidence.md diff --git a/devlog/_plan/260912_cline_client/evidence/cline-export.png b/devlog/_fin/260912_cline_client/evidence/cline-export.png similarity index 100% rename from devlog/_plan/260912_cline_client/evidence/cline-export.png rename to devlog/_fin/260912_cline_client/evidence/cline-export.png diff --git a/devlog/_plan/260912_cline_client/evidence/cline-integration.png b/devlog/_fin/260912_cline_client/evidence/cline-integration.png similarity index 100% rename from devlog/_plan/260912_cline_client/evidence/cline-integration.png rename to devlog/_fin/260912_cline_client/evidence/cline-integration.png diff --git a/devlog/_plan/260912_codex_gpt54_retirement/000_plan.md b/devlog/_fin/260912_codex_gpt54_retirement/000_plan.md similarity index 100% rename from devlog/_plan/260912_codex_gpt54_retirement/000_plan.md rename to devlog/_fin/260912_codex_gpt54_retirement/000_plan.md diff --git a/devlog/_plan/260912_codex_gpt54_retirement/010_catalog_removal.md b/devlog/_fin/260912_codex_gpt54_retirement/010_catalog_removal.md similarity index 100% rename from devlog/_plan/260912_codex_gpt54_retirement/010_catalog_removal.md rename to devlog/_fin/260912_codex_gpt54_retirement/010_catalog_removal.md diff --git a/devlog/_plan/260912_codex_gpt54_retirement/020_defaults_repoint.md b/devlog/_fin/260912_codex_gpt54_retirement/020_defaults_repoint.md similarity index 100% rename from devlog/_plan/260912_codex_gpt54_retirement/020_defaults_repoint.md rename to devlog/_fin/260912_codex_gpt54_retirement/020_defaults_repoint.md diff --git a/devlog/_plan/260912_codex_gpt54_retirement/030_surfaces_and_gate.md b/devlog/_fin/260912_codex_gpt54_retirement/030_surfaces_and_gate.md similarity index 100% rename from devlog/_plan/260912_codex_gpt54_retirement/030_surfaces_and_gate.md rename to devlog/_fin/260912_codex_gpt54_retirement/030_surfaces_and_gate.md diff --git a/devlog/_plan/260912_codex_gpt54_retirement/040_done.md b/devlog/_fin/260912_codex_gpt54_retirement/040_done.md similarity index 100% rename from devlog/_plan/260912_codex_gpt54_retirement/040_done.md rename to devlog/_fin/260912_codex_gpt54_retirement/040_done.md diff --git a/devlog/_plan/260912_codex_spark_retirement/000_plan.md b/devlog/_fin/260912_codex_spark_retirement/000_plan.md similarity index 100% rename from devlog/_plan/260912_codex_spark_retirement/000_plan.md rename to devlog/_fin/260912_codex_spark_retirement/000_plan.md diff --git a/devlog/_plan/260912_codex_spark_retirement/001_source.md b/devlog/_fin/260912_codex_spark_retirement/001_source.md similarity index 100% rename from devlog/_plan/260912_codex_spark_retirement/001_source.md rename to devlog/_fin/260912_codex_spark_retirement/001_source.md diff --git a/devlog/_plan/260912_codex_spark_retirement/010_implementation.md b/devlog/_fin/260912_codex_spark_retirement/010_implementation.md similarity index 100% rename from devlog/_plan/260912_codex_spark_retirement/010_implementation.md rename to devlog/_fin/260912_codex_spark_retirement/010_implementation.md diff --git a/devlog/_plan/260912_codex_spark_retirement/020_review.md b/devlog/_fin/260912_codex_spark_retirement/020_review.md similarity index 100% rename from devlog/_plan/260912_codex_spark_retirement/020_review.md rename to devlog/_fin/260912_codex_spark_retirement/020_review.md diff --git a/devlog/_plan/260912_codex_spark_retirement/030_delivery.md b/devlog/_fin/260912_codex_spark_retirement/030_delivery.md similarity index 100% rename from devlog/_plan/260912_codex_spark_retirement/030_delivery.md rename to devlog/_fin/260912_codex_spark_retirement/030_delivery.md diff --git a/devlog/_plan/260912_combo_carry/000_plan.md b/devlog/_fin/260912_combo_carry/000_plan.md similarity index 100% rename from devlog/_plan/260912_combo_carry/000_plan.md rename to devlog/_fin/260912_combo_carry/000_plan.md diff --git a/devlog/_plan/260912_combo_carry/010_runtime.md b/devlog/_fin/260912_combo_carry/010_runtime.md similarity index 100% rename from devlog/_plan/260912_combo_carry/010_runtime.md rename to devlog/_fin/260912_combo_carry/010_runtime.md diff --git a/devlog/_plan/260912_combo_carry/020_editor.md b/devlog/_fin/260912_combo_carry/020_editor.md similarity index 100% rename from devlog/_plan/260912_combo_carry/020_editor.md rename to devlog/_fin/260912_combo_carry/020_editor.md diff --git a/devlog/_plan/260912_combo_carry/030_verification.md b/devlog/_fin/260912_combo_carry/030_verification.md similarity index 100% rename from devlog/_plan/260912_combo_carry/030_verification.md rename to devlog/_fin/260912_combo_carry/030_verification.md diff --git a/devlog/_plan/260912_combo_carry/031_exhausted.png b/devlog/_fin/260912_combo_carry/031_exhausted.png similarity index 100% rename from devlog/_plan/260912_combo_carry/031_exhausted.png rename to devlog/_fin/260912_combo_carry/031_exhausted.png diff --git a/devlog/_plan/260912_combo_carry/032_unknown.png b/devlog/_fin/260912_combo_carry/032_unknown.png similarity index 100% rename from devlog/_plan/260912_combo_carry/032_unknown.png rename to devlog/_fin/260912_combo_carry/032_unknown.png diff --git a/devlog/_plan/260912_combo_carry/033_expired.png b/devlog/_fin/260912_combo_carry/033_expired.png similarity index 100% rename from devlog/_plan/260912_combo_carry/033_expired.png rename to devlog/_fin/260912_combo_carry/033_expired.png diff --git a/devlog/_plan/260912_combo_carry/034_render_evidence.md b/devlog/_fin/260912_combo_carry/034_render_evidence.md similarity index 100% rename from devlog/_plan/260912_combo_carry/034_render_evidence.md rename to devlog/_fin/260912_combo_carry/034_render_evidence.md diff --git a/devlog/_plan/260912_combo_carry/040_pnpm_ci_repair.md b/devlog/_fin/260912_combo_carry/040_pnpm_ci_repair.md similarity index 100% rename from devlog/_plan/260912_combo_carry/040_pnpm_ci_repair.md rename to devlog/_fin/260912_combo_carry/040_pnpm_ci_repair.md diff --git a/devlog/_plan/260912_combo_carry/050_active_reactivation_repair.md b/devlog/_fin/260912_combo_carry/050_active_reactivation_repair.md similarity index 100% rename from devlog/_plan/260912_combo_carry/050_active_reactivation_repair.md rename to devlog/_fin/260912_combo_carry/050_active_reactivation_repair.md diff --git a/devlog/_plan/260912_devin_hardening/000_plan.md b/devlog/_fin/260912_devin_hardening/000_plan.md similarity index 100% rename from devlog/_plan/260912_devin_hardening/000_plan.md rename to devlog/_fin/260912_devin_hardening/000_plan.md diff --git a/devlog/_plan/260912_devin_hardening/010_cli_token_transition.md b/devlog/_fin/260912_devin_hardening/010_cli_token_transition.md similarity index 100% rename from devlog/_plan/260912_devin_hardening/010_cli_token_transition.md rename to devlog/_fin/260912_devin_hardening/010_cli_token_transition.md diff --git a/devlog/_plan/260912_devin_hardening/020_cloud_direct_hardening.md b/devlog/_fin/260912_devin_hardening/020_cloud_direct_hardening.md similarity index 100% rename from devlog/_plan/260912_devin_hardening/020_cloud_direct_hardening.md rename to devlog/_fin/260912_devin_hardening/020_cloud_direct_hardening.md diff --git a/devlog/_plan/260912_devin_hardening/030_cached_token_display.md b/devlog/_fin/260912_devin_hardening/030_cached_token_display.md similarity index 100% rename from devlog/_plan/260912_devin_hardening/030_cached_token_display.md rename to devlog/_fin/260912_devin_hardening/030_cached_token_display.md diff --git a/devlog/_plan/260912_devin_hardening/040_stacked_delivery.md b/devlog/_fin/260912_devin_hardening/040_stacked_delivery.md similarity index 100% rename from devlog/_plan/260912_devin_hardening/040_stacked_delivery.md rename to devlog/_fin/260912_devin_hardening/040_stacked_delivery.md diff --git a/devlog/_plan/260912_devin_hardening/logs-devin-cached-restored.jpg b/devlog/_fin/260912_devin_hardening/logs-devin-cached-restored.jpg similarity index 100% rename from devlog/_plan/260912_devin_hardening/logs-devin-cached-restored.jpg rename to devlog/_fin/260912_devin_hardening/logs-devin-cached-restored.jpg diff --git a/devlog/_plan/260912_grok_reset_coupon_gui/000_plan.md b/devlog/_fin/260912_grok_reset_coupon_gui/000_plan.md similarity index 99% rename from devlog/_plan/260912_grok_reset_coupon_gui/000_plan.md rename to devlog/_fin/260912_grok_reset_coupon_gui/000_plan.md index e300fc35c0c..192e5dd2d7c 100644 --- a/devlog/_plan/260912_grok_reset_coupon_gui/000_plan.md +++ b/devlog/_fin/260912_grok_reset_coupon_gui/000_plan.md @@ -31,7 +31,7 @@ audit (`030`) are folded into the decisions, file map, verifiers and criteria be observes this unit's files. - **Stop condition:** merged into `dev` with exact-head CI green and `dev` ancestry proven. -- **Memory artifact:** `devlog/_plan/260912_grok_reset_coupon_gui/`, closing into +- **Memory artifact:** `devlog/_fin/260912_grok_reset_coupon_gui/`, closing into `devlog/_fin/` after the merge. - **Expected terminal outcomes:** DONE on merge; BLOCKED if review requires the server change this unit excluded; NEEDS_HUMAN if a second maintainer approval is diff --git a/devlog/_plan/260912_grok_reset_coupon_gui/005_status.md b/devlog/_fin/260912_grok_reset_coupon_gui/005_status.md similarity index 100% rename from devlog/_plan/260912_grok_reset_coupon_gui/005_status.md rename to devlog/_fin/260912_grok_reset_coupon_gui/005_status.md diff --git a/devlog/_plan/260912_grok_reset_coupon_gui/010_architect_dispositions.md b/devlog/_fin/260912_grok_reset_coupon_gui/010_architect_dispositions.md similarity index 100% rename from devlog/_plan/260912_grok_reset_coupon_gui/010_architect_dispositions.md rename to devlog/_fin/260912_grok_reset_coupon_gui/010_architect_dispositions.md diff --git a/devlog/_plan/260912_grok_reset_coupon_gui/020_reflection_gaps.md b/devlog/_fin/260912_grok_reset_coupon_gui/020_reflection_gaps.md similarity index 100% rename from devlog/_plan/260912_grok_reset_coupon_gui/020_reflection_gaps.md rename to devlog/_fin/260912_grok_reset_coupon_gui/020_reflection_gaps.md diff --git a/devlog/_plan/260912_grok_reset_coupon_gui/030_audit_round1.md b/devlog/_fin/260912_grok_reset_coupon_gui/030_audit_round1.md similarity index 100% rename from devlog/_plan/260912_grok_reset_coupon_gui/030_audit_round1.md rename to devlog/_fin/260912_grok_reset_coupon_gui/030_audit_round1.md diff --git a/devlog/_plan/260912_grok_reset_coupon_gui/evidence/architect-round1.md b/devlog/_fin/260912_grok_reset_coupon_gui/evidence/architect-round1.md similarity index 100% rename from devlog/_plan/260912_grok_reset_coupon_gui/evidence/architect-round1.md rename to devlog/_fin/260912_grok_reset_coupon_gui/evidence/architect-round1.md diff --git a/devlog/_plan/260912_grok_reset_coupons/000_plan.md b/devlog/_fin/260912_grok_reset_coupons/000_plan.md similarity index 95% rename from devlog/_plan/260912_grok_reset_coupons/000_plan.md rename to devlog/_fin/260912_grok_reset_coupons/000_plan.md index 22ba5d65b9f..001038f8a1c 100644 --- a/devlog/_plan/260912_grok_reset_coupons/000_plan.md +++ b/devlog/_fin/260912_grok_reset_coupons/000_plan.md @@ -18,7 +18,7 @@ the same safety shape they already know. - **Non-goals:** no auto-redeem in this unit (opt-in auto-redeem is a follow-up); no GUI surface; no changes to the Codex reset-credit path; no new dependency (hand-rolled gRPC-Web codec, no @bufbuild/protobuf runtime import). - **Verifier:** `bun test tests/providers/xai/grok-reset-coupons.test.ts` (targets the new test file directly), `bun run typecheck` (package.json:11 "bun x tsc --noEmit"), `bun run test` (package.json:12 "bun scripts/test.ts" — full tree, reads all domains incl. our layout registrations), `bun run privacy:scan` (package.json "bun scripts/privacy-scan.ts" — scans the tree incl. new files). Live smoke (sanitized) re-proves the read path against the real endpoint. - **Stop condition:** all criteria met (goalplan c1–c6) and the PR is merged with exact-head CI + issue closed; report DONE. Missing authority (push/merge refusal) reports BLOCKED. -- **Memory artifact:** this unit (devlog/_plan/260912_grok_reset_coupons/, moves to _fin at wp4 D); goalplan + ledger under .codexclaw/goalplans/implement-grok-reset-coupon-support-in-opencodex/; evidence under .codexclaw/evidence/. +- **Memory artifact:** this unit (devlog/_fin/260912_grok_reset_coupons/, moves to _fin at wp4 D); goalplan + ledger under .codexclaw/goalplans/implement-grok-reset-coupon-support-in-opencodex/; evidence under .codexclaw/evidence/. - **Expected terminal outcomes:** DONE (all criteria + merged), BLOCKED (missing external authority or upstream contract change), BUDGET_EXHAUSTED (host bounds), NEEDS_HUMAN (upstream schema drift on RedeemReset success shape). - **Escalation condition:** upstream rejects the documented RedeemReset request shape on a real redeem → stop, report, ask operator how to proceed (spending a coupon is operator-owned). Main reclaims a lane after two distinct agents fail its packet (DISPATCH-RETIRE-01); pushing a slice to a worker requires a P-phase amendment. diff --git a/devlog/_plan/260912_grok_reset_coupons/001_survey_seams.md b/devlog/_fin/260912_grok_reset_coupons/001_survey_seams.md similarity index 100% rename from devlog/_plan/260912_grok_reset_coupons/001_survey_seams.md rename to devlog/_fin/260912_grok_reset_coupons/001_survey_seams.md diff --git a/devlog/_plan/260912_grok_reset_coupons/005_status.md b/devlog/_fin/260912_grok_reset_coupons/005_status.md similarity index 100% rename from devlog/_plan/260912_grok_reset_coupons/005_status.md rename to devlog/_fin/260912_grok_reset_coupons/005_status.md diff --git a/devlog/_plan/260912_grok_reset_coupons/010_phase1_core_client.md b/devlog/_fin/260912_grok_reset_coupons/010_phase1_core_client.md similarity index 100% rename from devlog/_plan/260912_grok_reset_coupons/010_phase1_core_client.md rename to devlog/_fin/260912_grok_reset_coupons/010_phase1_core_client.md diff --git a/devlog/_plan/260912_grok_reset_coupons/020_phase2_surfaces.md b/devlog/_fin/260912_grok_reset_coupons/020_phase2_surfaces.md similarity index 100% rename from devlog/_plan/260912_grok_reset_coupons/020_phase2_surfaces.md rename to devlog/_fin/260912_grok_reset_coupons/020_phase2_surfaces.md diff --git a/devlog/_plan/260912_grok_reset_coupons/030_phase3_delivery.md b/devlog/_fin/260912_grok_reset_coupons/030_phase3_delivery.md similarity index 99% rename from devlog/_plan/260912_grok_reset_coupons/030_phase3_delivery.md rename to devlog/_fin/260912_grok_reset_coupons/030_phase3_delivery.md index a89db8f8ee9..2c014eaee95 100644 --- a/devlog/_plan/260912_grok_reset_coupons/030_phase3_delivery.md +++ b/devlog/_fin/260912_grok_reset_coupons/030_phase3_delivery.md @@ -72,7 +72,7 @@ The Grok reset coupons capability introduces both a new CLI command under `ocx a Upon landing all Phase 1 and Phase 2 changes on `dev`, promote the planning directory to finished status: -- **Source:** `devlog/_plan/260912_grok_reset_coupons/` +- **Source:** `devlog/_fin/260912_grok_reset_coupons/` - **Destination:** `devlog/_fin/260912_grok_reset_coupons/` - **Action:** Move the directory and add `000_fin_summary.md` documenting: 1. Live verification against `grok.com/prod_mc_billing.ConsumerUiSvc`. diff --git a/devlog/_plan/260912_history_containment/000_plan.md b/devlog/_fin/260912_history_containment/000_plan.md similarity index 100% rename from devlog/_plan/260912_history_containment/000_plan.md rename to devlog/_fin/260912_history_containment/000_plan.md diff --git a/devlog/_plan/260912_history_containment/010_containment.md b/devlog/_fin/260912_history_containment/010_containment.md similarity index 100% rename from devlog/_plan/260912_history_containment/010_containment.md rename to devlog/_fin/260912_history_containment/010_containment.md diff --git a/devlog/_plan/260912_history_containment/020_continuation.md b/devlog/_fin/260912_history_containment/020_continuation.md similarity index 100% rename from devlog/_plan/260912_history_containment/020_continuation.md rename to devlog/_fin/260912_history_containment/020_continuation.md diff --git a/devlog/_plan/260912_history_containment/030_relay.md b/devlog/_fin/260912_history_containment/030_relay.md similarity index 100% rename from devlog/_plan/260912_history_containment/030_relay.md rename to devlog/_fin/260912_history_containment/030_relay.md diff --git a/devlog/_plan/260912_history_containment/040_verification.md b/devlog/_fin/260912_history_containment/040_verification.md similarity index 100% rename from devlog/_plan/260912_history_containment/040_verification.md rename to devlog/_fin/260912_history_containment/040_verification.md diff --git a/devlog/_plan/260912_history_containment/051_resume_status.md b/devlog/_fin/260912_history_containment/051_resume_status.md similarity index 100% rename from devlog/_plan/260912_history_containment/051_resume_status.md rename to devlog/_fin/260912_history_containment/051_resume_status.md diff --git a/devlog/_plan/260912_muse_device_oauth/000_plan.md b/devlog/_fin/260912_muse_device_oauth/000_plan.md similarity index 100% rename from devlog/_plan/260912_muse_device_oauth/000_plan.md rename to devlog/_fin/260912_muse_device_oauth/000_plan.md diff --git a/devlog/_plan/260912_muse_device_oauth/001_reference_measurements.md b/devlog/_fin/260912_muse_device_oauth/001_reference_measurements.md similarity index 100% rename from devlog/_plan/260912_muse_device_oauth/001_reference_measurements.md rename to devlog/_fin/260912_muse_device_oauth/001_reference_measurements.md diff --git a/devlog/_plan/260912_muse_device_oauth/002_design_decisions.md b/devlog/_fin/260912_muse_device_oauth/002_design_decisions.md similarity index 100% rename from devlog/_plan/260912_muse_device_oauth/002_design_decisions.md rename to devlog/_fin/260912_muse_device_oauth/002_design_decisions.md diff --git a/devlog/_plan/260912_muse_device_oauth/010_wp2_device_core.md b/devlog/_fin/260912_muse_device_oauth/010_wp2_device_core.md similarity index 99% rename from devlog/_plan/260912_muse_device_oauth/010_wp2_device_core.md rename to devlog/_fin/260912_muse_device_oauth/010_wp2_device_core.md index 5a93ec9ea8a..15f1d875919 100644 --- a/devlog/_plan/260912_muse_device_oauth/010_wp2_device_core.md +++ b/devlog/_fin/260912_muse_device_oauth/010_wp2_device_core.md @@ -25,7 +25,7 @@ Add beside `KiroOAuthMetadata`, whose role this mirrors (`002` §A): * * The Model API is authenticated by the `LLM|` key in `access`; this token authenticates * the Meta ACCOUNT and exists only to mint that key and to read subscription usage - * (devlog/_plan/260912_muse_device_oauth/002 A). Keeping it out of `access` is what lets + * (devlog/_fin/260912_muse_device_oauth/002 A). Keeping it out of `access` is what lets * every request path stay unchanged. * * It must never be added to `OAuthAccountSummary` (src/oauth/index.ts:1803) or to @@ -121,7 +121,7 @@ branches in milliseconds and paying real seconds per branch the way * resulting account token into the `LLM|` Model API key our request path sends as a * bearer. * - * Protocol source: devlog/_plan/260912_muse_device_oauth/001_reference_measurements.md. + * Protocol source: devlog/_fin/260912_muse_device_oauth/001_reference_measurements.md. * It is second-party, not vendor documentation, so every response is parsed defensively * and every failure names a kind instead of throwing a bare string. * diff --git a/devlog/_plan/260912_muse_device_oauth/020_wp3_login_integration.md b/devlog/_fin/260912_muse_device_oauth/020_wp3_login_integration.md similarity index 100% rename from devlog/_plan/260912_muse_device_oauth/020_wp3_login_integration.md rename to devlog/_fin/260912_muse_device_oauth/020_wp3_login_integration.md diff --git a/devlog/_plan/260912_muse_device_oauth/030_wp4_header_and_quota.md b/devlog/_fin/260912_muse_device_oauth/030_wp4_header_and_quota.md similarity index 100% rename from devlog/_plan/260912_muse_device_oauth/030_wp4_header_and_quota.md rename to devlog/_fin/260912_muse_device_oauth/030_wp4_header_and_quota.md diff --git a/devlog/_plan/260912_muse_device_oauth/040_wp5_tests_and_gate.md b/devlog/_fin/260912_muse_device_oauth/040_wp5_tests_and_gate.md similarity index 100% rename from devlog/_plan/260912_muse_device_oauth/040_wp5_tests_and_gate.md rename to devlog/_fin/260912_muse_device_oauth/040_wp5_tests_and_gate.md diff --git a/devlog/_plan/260912_muse_device_oauth/050_delivery_record.md b/devlog/_fin/260912_muse_device_oauth/050_delivery_record.md similarity index 100% rename from devlog/_plan/260912_muse_device_oauth/050_delivery_record.md rename to devlog/_fin/260912_muse_device_oauth/050_delivery_record.md diff --git a/devlog/_plan/260912_muse_tool_name_alias/000_plan.md b/devlog/_fin/260912_muse_tool_name_alias/000_plan.md similarity index 100% rename from devlog/_plan/260912_muse_tool_name_alias/000_plan.md rename to devlog/_fin/260912_muse_tool_name_alias/000_plan.md diff --git a/devlog/_plan/260912_omo_client_integration/000_plan.md b/devlog/_fin/260912_omo_client_integration/000_plan.md similarity index 100% rename from devlog/_plan/260912_omo_client_integration/000_plan.md rename to devlog/_fin/260912_omo_client_integration/000_plan.md diff --git a/devlog/_plan/260912_omo_client_integration/001_omo_contract.md b/devlog/_fin/260912_omo_client_integration/001_omo_contract.md similarity index 100% rename from devlog/_plan/260912_omo_client_integration/001_omo_contract.md rename to devlog/_fin/260912_omo_client_integration/001_omo_contract.md diff --git a/devlog/_plan/260912_omo_client_integration/002_registration_checklist.md b/devlog/_fin/260912_omo_client_integration/002_registration_checklist.md similarity index 100% rename from devlog/_plan/260912_omo_client_integration/002_registration_checklist.md rename to devlog/_fin/260912_omo_client_integration/002_registration_checklist.md diff --git a/devlog/_plan/260912_omo_client_integration/003_brand_mark_provenance.md b/devlog/_fin/260912_omo_client_integration/003_brand_mark_provenance.md similarity index 100% rename from devlog/_plan/260912_omo_client_integration/003_brand_mark_provenance.md rename to devlog/_fin/260912_omo_client_integration/003_brand_mark_provenance.md diff --git a/devlog/_plan/260912_omo_client_integration/010_wp2_backend.md b/devlog/_fin/260912_omo_client_integration/010_wp2_backend.md similarity index 100% rename from devlog/_plan/260912_omo_client_integration/010_wp2_backend.md rename to devlog/_fin/260912_omo_client_integration/010_wp2_backend.md diff --git a/devlog/_plan/260912_omo_client_integration/020_wp3_gui.md b/devlog/_fin/260912_omo_client_integration/020_wp3_gui.md similarity index 100% rename from devlog/_plan/260912_omo_client_integration/020_wp3_gui.md rename to devlog/_fin/260912_omo_client_integration/020_wp3_gui.md diff --git a/devlog/_plan/260912_omo_client_integration/030_wp4_docs.md b/devlog/_fin/260912_omo_client_integration/030_wp4_docs.md similarity index 100% rename from devlog/_plan/260912_omo_client_integration/030_wp4_docs.md rename to devlog/_fin/260912_omo_client_integration/030_wp4_docs.md diff --git a/devlog/_plan/260912_omo_client_integration/040_wp5_verification.md b/devlog/_fin/260912_omo_client_integration/040_wp5_verification.md similarity index 100% rename from devlog/_plan/260912_omo_client_integration/040_wp5_verification.md rename to devlog/_fin/260912_omo_client_integration/040_wp5_verification.md diff --git a/devlog/_plan/260912_omo_client_integration/050_wp6_gjc_rename.md b/devlog/_fin/260912_omo_client_integration/050_wp6_gjc_rename.md similarity index 100% rename from devlog/_plan/260912_omo_client_integration/050_wp6_gjc_rename.md rename to devlog/_fin/260912_omo_client_integration/050_wp6_gjc_rename.md diff --git a/devlog/_plan/260912_omo_client_integration/060_wp3_rendered_proof.md b/devlog/_fin/260912_omo_client_integration/060_wp3_rendered_proof.md similarity index 100% rename from devlog/_plan/260912_omo_client_integration/060_wp3_rendered_proof.md rename to devlog/_fin/260912_omo_client_integration/060_wp3_rendered_proof.md diff --git a/devlog/_plan/260912_omo_client_integration/090_outcome.md b/devlog/_fin/260912_omo_client_integration/090_outcome.md similarity index 100% rename from devlog/_plan/260912_omo_client_integration/090_outcome.md rename to devlog/_fin/260912_omo_client_integration/090_outcome.md diff --git a/devlog/_plan/260912_omo_client_integration/evidence/integrations-omo-tab.png b/devlog/_fin/260912_omo_client_integration/evidence/integrations-omo-tab.png similarity index 100% rename from devlog/_plan/260912_omo_client_integration/evidence/integrations-omo-tab.png rename to devlog/_fin/260912_omo_client_integration/evidence/integrations-omo-tab.png diff --git a/devlog/_plan/260912_operations/000_plan.md b/devlog/_fin/260912_operations/000_plan.md similarity index 100% rename from devlog/_plan/260912_operations/000_plan.md rename to devlog/_fin/260912_operations/000_plan.md diff --git a/devlog/_plan/260912_operations/001_roadmap_audit.md b/devlog/_fin/260912_operations/001_roadmap_audit.md similarity index 100% rename from devlog/_plan/260912_operations/001_roadmap_audit.md rename to devlog/_fin/260912_operations/001_roadmap_audit.md diff --git a/devlog/_plan/260912_operations/010_update.md b/devlog/_fin/260912_operations/010_update.md similarity index 100% rename from devlog/_plan/260912_operations/010_update.md rename to devlog/_fin/260912_operations/010_update.md diff --git a/devlog/_plan/260912_operations/020_listeners.md b/devlog/_fin/260912_operations/020_listeners.md similarity index 100% rename from devlog/_plan/260912_operations/020_listeners.md rename to devlog/_fin/260912_operations/020_listeners.md diff --git a/devlog/_plan/260912_operations/030_totals.md b/devlog/_fin/260912_operations/030_totals.md similarity index 100% rename from devlog/_plan/260912_operations/030_totals.md rename to devlog/_fin/260912_operations/030_totals.md diff --git a/devlog/_plan/260912_operations/040_client_usage.md b/devlog/_fin/260912_operations/040_client_usage.md similarity index 100% rename from devlog/_plan/260912_operations/040_client_usage.md rename to devlog/_fin/260912_operations/040_client_usage.md diff --git a/devlog/_plan/260912_operations/050_pairing.md b/devlog/_fin/260912_operations/050_pairing.md similarity index 100% rename from devlog/_plan/260912_operations/050_pairing.md rename to devlog/_fin/260912_operations/050_pairing.md diff --git a/devlog/_plan/260912_operations/060_transport.md b/devlog/_fin/260912_operations/060_transport.md similarity index 100% rename from devlog/_plan/260912_operations/060_transport.md rename to devlog/_fin/260912_operations/060_transport.md diff --git a/devlog/_plan/260912_operations/070_verification.md b/devlog/_fin/260912_operations/070_verification.md similarity index 100% rename from devlog/_plan/260912_operations/070_verification.md rename to devlog/_fin/260912_operations/070_verification.md diff --git a/devlog/_plan/260912_operations/080_update_events.md b/devlog/_fin/260912_operations/080_update_events.md similarity index 100% rename from devlog/_plan/260912_operations/080_update_events.md rename to devlog/_fin/260912_operations/080_update_events.md diff --git a/devlog/_plan/260912_operations/090_listener_diagnostic.md b/devlog/_fin/260912_operations/090_listener_diagnostic.md similarity index 100% rename from devlog/_plan/260912_operations/090_listener_diagnostic.md rename to devlog/_fin/260912_operations/090_listener_diagnostic.md diff --git a/devlog/_plan/260912_operations/100_usage_transport.md b/devlog/_fin/260912_operations/100_usage_transport.md similarity index 100% rename from devlog/_plan/260912_operations/100_usage_transport.md rename to devlog/_fin/260912_operations/100_usage_transport.md diff --git a/devlog/_plan/260912_operations/110_resume_status.md b/devlog/_fin/260912_operations/110_resume_status.md similarity index 100% rename from devlog/_plan/260912_operations/110_resume_status.md rename to devlog/_fin/260912_operations/110_resume_status.md diff --git a/devlog/_plan/260912_operations/120_coordinator_probe.md b/devlog/_fin/260912_operations/120_coordinator_probe.md similarity index 100% rename from devlog/_plan/260912_operations/120_coordinator_probe.md rename to devlog/_fin/260912_operations/120_coordinator_probe.md diff --git a/devlog/_plan/260912_phantom_account_short_quota/000_plan.md b/devlog/_fin/260912_phantom_account_short_quota/000_plan.md similarity index 100% rename from devlog/_plan/260912_phantom_account_short_quota/000_plan.md rename to devlog/_fin/260912_phantom_account_short_quota/000_plan.md diff --git a/devlog/_plan/260912_phantom_account_short_quota/010_phase1_drop_elapsed_short_carry.md b/devlog/_fin/260912_phantom_account_short_quota/010_phase1_drop_elapsed_short_carry.md similarity index 100% rename from devlog/_plan/260912_phantom_account_short_quota/010_phase1_drop_elapsed_short_carry.md rename to devlog/_fin/260912_phantom_account_short_quota/010_phase1_drop_elapsed_short_carry.md diff --git a/devlog/_plan/260912_provider_catalog_unified_search/000_plan.md b/devlog/_fin/260912_provider_catalog_unified_search/000_plan.md similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/000_plan.md rename to devlog/_fin/260912_provider_catalog_unified_search/000_plan.md diff --git a/devlog/_plan/260912_provider_catalog_unified_search/010_context.md b/devlog/_fin/260912_provider_catalog_unified_search/010_context.md similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/010_context.md rename to devlog/_fin/260912_provider_catalog_unified_search/010_context.md diff --git a/devlog/_plan/260912_provider_catalog_unified_search/015_codesign_grok.md b/devlog/_fin/260912_provider_catalog_unified_search/015_codesign_grok.md similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/015_codesign_grok.md rename to devlog/_fin/260912_provider_catalog_unified_search/015_codesign_grok.md diff --git a/devlog/_plan/260912_provider_catalog_unified_search/016_audit_corrections.md b/devlog/_fin/260912_provider_catalog_unified_search/016_audit_corrections.md similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/016_audit_corrections.md rename to devlog/_fin/260912_provider_catalog_unified_search/016_audit_corrections.md diff --git a/devlog/_plan/260912_provider_catalog_unified_search/020_r3_local_tab.md b/devlog/_fin/260912_provider_catalog_unified_search/020_r3_local_tab.md similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/020_r3_local_tab.md rename to devlog/_fin/260912_provider_catalog_unified_search/020_r3_local_tab.md diff --git a/devlog/_plan/260912_provider_catalog_unified_search/030_r2_note_clamp.md b/devlog/_fin/260912_provider_catalog_unified_search/030_r2_note_clamp.md similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/030_r2_note_clamp.md rename to devlog/_fin/260912_provider_catalog_unified_search/030_r2_note_clamp.md diff --git a/devlog/_plan/260912_provider_catalog_unified_search/040_r1_unified_search.md b/devlog/_fin/260912_provider_catalog_unified_search/040_r1_unified_search.md similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/040_r1_unified_search.md rename to devlog/_fin/260912_provider_catalog_unified_search/040_r1_unified_search.md diff --git a/devlog/_plan/260912_provider_catalog_unified_search/050_delivery.md b/devlog/_fin/260912_provider_catalog_unified_search/050_delivery.md similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/050_delivery.md rename to devlog/_fin/260912_provider_catalog_unified_search/050_delivery.md diff --git a/devlog/_plan/260912_provider_catalog_unified_search/060_delivery_record.md b/devlog/_fin/260912_provider_catalog_unified_search/060_delivery_record.md similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/060_delivery_record.md rename to devlog/_fin/260912_provider_catalog_unified_search/060_delivery_record.md diff --git a/devlog/_plan/260912_provider_catalog_unified_search/evidence/grok-4.6-codesign.md b/devlog/_fin/260912_provider_catalog_unified_search/evidence/grok-4.6-codesign.md similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/evidence/grok-4.6-codesign.md rename to devlog/_fin/260912_provider_catalog_unified_search/evidence/grok-4.6-codesign.md diff --git a/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp2-local-tab.png b/devlog/_fin/260912_provider_catalog_unified_search/evidence/wp2-local-tab.png similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/evidence/wp2-local-tab.png rename to devlog/_fin/260912_provider_catalog_unified_search/evidence/wp2-local-tab.png diff --git a/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp3-note-clamp.png b/devlog/_fin/260912_provider_catalog_unified_search/evidence/wp3-note-clamp.png similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/evidence/wp3-note-clamp.png rename to devlog/_fin/260912_provider_catalog_unified_search/evidence/wp3-note-clamp.png diff --git a/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp3-note-popup.png b/devlog/_fin/260912_provider_catalog_unified_search/evidence/wp3-note-popup.png similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/evidence/wp3-note-popup.png rename to devlog/_fin/260912_provider_catalog_unified_search/evidence/wp3-note-popup.png diff --git a/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-browse-mode.png b/devlog/_fin/260912_provider_catalog_unified_search/evidence/wp4-browse-mode.png similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-browse-mode.png rename to devlog/_fin/260912_provider_catalog_unified_search/evidence/wp4-browse-mode.png diff --git a/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-search-accounts.png b/devlog/_fin/260912_provider_catalog_unified_search/evidence/wp4-search-accounts.png similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-search-accounts.png rename to devlog/_fin/260912_provider_catalog_unified_search/evidence/wp4-search-accounts.png diff --git a/devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-search-groups.png b/devlog/_fin/260912_provider_catalog_unified_search/evidence/wp4-search-groups.png similarity index 100% rename from devlog/_plan/260912_provider_catalog_unified_search/evidence/wp4-search-groups.png rename to devlog/_fin/260912_provider_catalog_unified_search/evidence/wp4-search-groups.png diff --git a/devlog/_plan/260912_reasoning_effort_downgrade/000_decision.md b/devlog/_fin/260912_reasoning_effort_downgrade/000_decision.md similarity index 96% rename from devlog/_plan/260912_reasoning_effort_downgrade/000_decision.md rename to devlog/_fin/260912_reasoning_effort_downgrade/000_decision.md index 67499186afe..b83a14ca267 100644 --- a/devlog/_plan/260912_reasoning_effort_downgrade/000_decision.md +++ b/devlog/_fin/260912_reasoning_effort_downgrade/000_decision.md @@ -5,7 +5,7 @@ When a routed upstream answers 400/403 and names reasoning effort in the body, the pipeline records that (provider, model, effort) as refused, replays the request once at the next lower published rung, and keeps the rung out of every later ladder (see the metadata record in -devlog/_plan/260912_reasoning_metadata/). The attempt is logged with recovery kind +devlog/_fin/260912_reasoning_metadata/). The attempt is logged with recovery kind reasoning-effort-downgrade, so requestedEffort and effectiveEffort stay distinguishable in usage. ## Why the ladder is not enough diff --git a/devlog/_plan/260912_reasoning_metadata/000_decision.md b/devlog/_fin/260912_reasoning_metadata/000_decision.md similarity index 100% rename from devlog/_plan/260912_reasoning_metadata/000_decision.md rename to devlog/_fin/260912_reasoning_metadata/000_decision.md diff --git a/devlog/_plan/260912_release_regression_train/000_plan.md b/devlog/_fin/260912_release_regression_train/000_plan.md similarity index 100% rename from devlog/_plan/260912_release_regression_train/000_plan.md rename to devlog/_fin/260912_release_regression_train/000_plan.md diff --git a/devlog/_plan/260912_release_regression_train/010_ci_repair.md b/devlog/_fin/260912_release_regression_train/010_ci_repair.md similarity index 100% rename from devlog/_plan/260912_release_regression_train/010_ci_repair.md rename to devlog/_fin/260912_release_regression_train/010_ci_repair.md diff --git a/devlog/_plan/260912_release_regression_train/020_regression_sweep.md b/devlog/_fin/260912_release_regression_train/020_regression_sweep.md similarity index 100% rename from devlog/_plan/260912_release_regression_train/020_regression_sweep.md rename to devlog/_fin/260912_release_regression_train/020_regression_sweep.md diff --git a/devlog/_plan/260912_release_regression_train/025_sweep_results.md b/devlog/_fin/260912_release_regression_train/025_sweep_results.md similarity index 100% rename from devlog/_plan/260912_release_regression_train/025_sweep_results.md rename to devlog/_fin/260912_release_regression_train/025_sweep_results.md diff --git a/devlog/_plan/260912_release_regression_train/030_defect_landing.md b/devlog/_fin/260912_release_regression_train/030_defect_landing.md similarity index 100% rename from devlog/_plan/260912_release_regression_train/030_defect_landing.md rename to devlog/_fin/260912_release_regression_train/030_defect_landing.md diff --git a/devlog/_plan/260912_release_regression_train/040_preview_release.md b/devlog/_fin/260912_release_regression_train/040_preview_release.md similarity index 100% rename from devlog/_plan/260912_release_regression_train/040_preview_release.md rename to devlog/_fin/260912_release_regression_train/040_preview_release.md diff --git a/devlog/_plan/260912_release_regression_train/050_main_release.md b/devlog/_fin/260912_release_regression_train/050_main_release.md similarity index 100% rename from devlog/_plan/260912_release_regression_train/050_main_release.md rename to devlog/_fin/260912_release_regression_train/050_main_release.md diff --git a/devlog/_plan/260912_remote_workspace_carry/000_plan.md b/devlog/_fin/260912_remote_workspace_carry/000_plan.md similarity index 100% rename from devlog/_plan/260912_remote_workspace_carry/000_plan.md rename to devlog/_fin/260912_remote_workspace_carry/000_plan.md diff --git a/devlog/_plan/260912_remote_workspace_carry/010_protocol.md b/devlog/_fin/260912_remote_workspace_carry/010_protocol.md similarity index 100% rename from devlog/_plan/260912_remote_workspace_carry/010_protocol.md rename to devlog/_fin/260912_remote_workspace_carry/010_protocol.md diff --git a/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md b/devlog/_fin/260912_remote_workspace_carry/020_executor_runtime.md similarity index 100% rename from devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md rename to devlog/_fin/260912_remote_workspace_carry/020_executor_runtime.md diff --git a/devlog/_plan/260912_remote_workspace_carry/030_integration.md b/devlog/_fin/260912_remote_workspace_carry/030_integration.md similarity index 100% rename from devlog/_plan/260912_remote_workspace_carry/030_integration.md rename to devlog/_fin/260912_remote_workspace_carry/030_integration.md diff --git a/devlog/_plan/260912_remote_workspace_carry/040_hosted_validation.md b/devlog/_fin/260912_remote_workspace_carry/040_hosted_validation.md similarity index 100% rename from devlog/_plan/260912_remote_workspace_carry/040_hosted_validation.md rename to devlog/_fin/260912_remote_workspace_carry/040_hosted_validation.md diff --git a/devlog/_plan/260912_remote_workspace_carry/041_resume_ci_repair.md b/devlog/_fin/260912_remote_workspace_carry/041_resume_ci_repair.md similarity index 100% rename from devlog/_plan/260912_remote_workspace_carry/041_resume_ci_repair.md rename to devlog/_fin/260912_remote_workspace_carry/041_resume_ci_repair.md diff --git a/devlog/_plan/260912_remote_workspace_carry/assets/041_desktop_read_only.png b/devlog/_fin/260912_remote_workspace_carry/assets/041_desktop_read_only.png similarity index 100% rename from devlog/_plan/260912_remote_workspace_carry/assets/041_desktop_read_only.png rename to devlog/_fin/260912_remote_workspace_carry/assets/041_desktop_read_only.png diff --git a/devlog/_plan/260912_remote_workspace_carry/assets/042_desktop_files_only.png b/devlog/_fin/260912_remote_workspace_carry/assets/042_desktop_files_only.png similarity index 100% rename from devlog/_plan/260912_remote_workspace_carry/assets/042_desktop_files_only.png rename to devlog/_fin/260912_remote_workspace_carry/assets/042_desktop_files_only.png diff --git a/devlog/_plan/260912_remote_workspace_carry/screenshots/remote-workspace-hub-dashboard.png b/devlog/_fin/260912_remote_workspace_carry/screenshots/remote-workspace-hub-dashboard.png similarity index 100% rename from devlog/_plan/260912_remote_workspace_carry/screenshots/remote-workspace-hub-dashboard.png rename to devlog/_fin/260912_remote_workspace_carry/screenshots/remote-workspace-hub-dashboard.png diff --git a/devlog/_plan/260912_remote_workspace_carry/screenshots/remote-workspace-pairing-code.png b/devlog/_fin/260912_remote_workspace_carry/screenshots/remote-workspace-pairing-code.png similarity index 100% rename from devlog/_plan/260912_remote_workspace_carry/screenshots/remote-workspace-pairing-code.png rename to devlog/_fin/260912_remote_workspace_carry/screenshots/remote-workspace-pairing-code.png diff --git a/devlog/_plan/260912_thinking_contract/000_plan.md b/devlog/_fin/260912_thinking_contract/000_plan.md similarity index 100% rename from devlog/_plan/260912_thinking_contract/000_plan.md rename to devlog/_fin/260912_thinking_contract/000_plan.md diff --git a/devlog/_plan/260912_thinking_contract/001_sources.md b/devlog/_fin/260912_thinking_contract/001_sources.md similarity index 100% rename from devlog/_plan/260912_thinking_contract/001_sources.md rename to devlog/_fin/260912_thinking_contract/001_sources.md diff --git a/devlog/_plan/260912_thinking_contract/010_presentation.md b/devlog/_fin/260912_thinking_contract/010_presentation.md similarity index 100% rename from devlog/_plan/260912_thinking_contract/010_presentation.md rename to devlog/_fin/260912_thinking_contract/010_presentation.md diff --git a/devlog/_plan/260912_thinking_contract/020_transport_hint.md b/devlog/_fin/260912_thinking_contract/020_transport_hint.md similarity index 100% rename from devlog/_plan/260912_thinking_contract/020_transport_hint.md rename to devlog/_fin/260912_thinking_contract/020_transport_hint.md diff --git a/devlog/_plan/260912_thinking_contract/030_spark.md b/devlog/_fin/260912_thinking_contract/030_spark.md similarity index 100% rename from devlog/_plan/260912_thinking_contract/030_spark.md rename to devlog/_fin/260912_thinking_contract/030_spark.md diff --git a/devlog/_plan/260912_thinking_contract/040_delivery.md b/devlog/_fin/260912_thinking_contract/040_delivery.md similarity index 100% rename from devlog/_plan/260912_thinking_contract/040_delivery.md rename to devlog/_fin/260912_thinking_contract/040_delivery.md diff --git a/devlog/_plan/260912_thinking_contract/050_refresh.md b/devlog/_fin/260912_thinking_contract/050_refresh.md similarity index 100% rename from devlog/_plan/260912_thinking_contract/050_refresh.md rename to devlog/_fin/260912_thinking_contract/050_refresh.md diff --git a/devlog/_plan/260912_v2_contracts/000_plan.md b/devlog/_fin/260912_v2_contracts/000_plan.md similarity index 100% rename from devlog/_plan/260912_v2_contracts/000_plan.md rename to devlog/_fin/260912_v2_contracts/000_plan.md diff --git a/devlog/_plan/260912_v2_contracts/010_plaintext.md b/devlog/_fin/260912_v2_contracts/010_plaintext.md similarity index 100% rename from devlog/_plan/260912_v2_contracts/010_plaintext.md rename to devlog/_fin/260912_v2_contracts/010_plaintext.md diff --git a/devlog/_plan/260912_v2_contracts/020_recovery.md b/devlog/_fin/260912_v2_contracts/020_recovery.md similarity index 100% rename from devlog/_plan/260912_v2_contracts/020_recovery.md rename to devlog/_fin/260912_v2_contracts/020_recovery.md diff --git a/devlog/_plan/260912_v2_contracts/030_native_identity.md b/devlog/_fin/260912_v2_contracts/030_native_identity.md similarity index 100% rename from devlog/_plan/260912_v2_contracts/030_native_identity.md rename to devlog/_fin/260912_v2_contracts/030_native_identity.md diff --git a/devlog/_plan/260912_v2_contracts/040_verification.md b/devlog/_fin/260912_v2_contracts/040_verification.md similarity index 100% rename from devlog/_plan/260912_v2_contracts/040_verification.md rename to devlog/_fin/260912_v2_contracts/040_verification.md diff --git a/devlog/_plan/260912_v2_contracts/050_resume_evidence.md b/devlog/_fin/260912_v2_contracts/050_resume_evidence.md similarity index 100% rename from devlog/_plan/260912_v2_contracts/050_resume_evidence.md rename to devlog/_fin/260912_v2_contracts/050_resume_evidence.md diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/000_plan.md b/devlog/_fin/260912_zcode_protocol_and_catalog/000_plan.md similarity index 100% rename from devlog/_plan/260912_zcode_protocol_and_catalog/000_plan.md rename to devlog/_fin/260912_zcode_protocol_and_catalog/000_plan.md diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/001_research_zai_model_facts.md b/devlog/_fin/260912_zcode_protocol_and_catalog/001_research_zai_model_facts.md similarity index 100% rename from devlog/_plan/260912_zcode_protocol_and_catalog/001_research_zai_model_facts.md rename to devlog/_fin/260912_zcode_protocol_and_catalog/001_research_zai_model_facts.md diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/002_research_zcode_bundle_schema.md b/devlog/_fin/260912_zcode_protocol_and_catalog/002_research_zcode_bundle_schema.md similarity index 100% rename from devlog/_plan/260912_zcode_protocol_and_catalog/002_research_zcode_bundle_schema.md rename to devlog/_fin/260912_zcode_protocol_and_catalog/002_research_zcode_bundle_schema.md diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/003_research_ocx_internals.md b/devlog/_fin/260912_zcode_protocol_and_catalog/003_research_ocx_internals.md similarity index 100% rename from devlog/_plan/260912_zcode_protocol_and_catalog/003_research_ocx_internals.md rename to devlog/_fin/260912_zcode_protocol_and_catalog/003_research_ocx_internals.md diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md b/devlog/_fin/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md similarity index 100% rename from devlog/_plan/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md rename to devlog/_fin/260912_zcode_protocol_and_catalog/010_wp2_zcode_export_responses.md diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/020_wp3_glm53_flash_modalities.md b/devlog/_fin/260912_zcode_protocol_and_catalog/020_wp3_glm53_flash_modalities.md similarity index 100% rename from devlog/_plan/260912_zcode_protocol_and_catalog/020_wp3_glm53_flash_modalities.md rename to devlog/_fin/260912_zcode_protocol_and_catalog/020_wp3_glm53_flash_modalities.md diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md b/devlog/_fin/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md similarity index 100% rename from devlog/_plan/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md rename to devlog/_fin/260912_zcode_protocol_and_catalog/030_wp4_zai_responses_default.md diff --git a/devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json b/devlog/_fin/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json similarity index 100% rename from devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json rename to devlog/_fin/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json diff --git a/devlog/_plan/260913_chat_refusal_translator/000_plan.md b/devlog/_fin/260913_chat_refusal_translator/000_plan.md similarity index 100% rename from devlog/_plan/260913_chat_refusal_translator/000_plan.md rename to devlog/_fin/260913_chat_refusal_translator/000_plan.md diff --git a/devlog/_plan/260913_chat_refusal_translator/010_rootcause_evidence.md b/devlog/_fin/260913_chat_refusal_translator/010_rootcause_evidence.md similarity index 100% rename from devlog/_plan/260913_chat_refusal_translator/010_rootcause_evidence.md rename to devlog/_fin/260913_chat_refusal_translator/010_rootcause_evidence.md diff --git a/devlog/_plan/260913_chat_refusal_translator/020_fix_refusal_scope.md b/devlog/_fin/260913_chat_refusal_translator/020_fix_refusal_scope.md similarity index 100% rename from devlog/_plan/260913_chat_refusal_translator/020_fix_refusal_scope.md rename to devlog/_fin/260913_chat_refusal_translator/020_fix_refusal_scope.md diff --git a/devlog/_fin/260913_contributor_carry_train/000_plan.md b/devlog/_fin/260913_contributor_carry_train/000_plan.md index d3b03d7fe3e..797255df8eb 100644 --- a/devlog/_fin/260913_contributor_carry_train/000_plan.md +++ b/devlog/_fin/260913_contributor_carry_train/000_plan.md @@ -10,7 +10,7 @@ request owning them. The goal also closes everything those landings actually resolve. `origin/dev` was `2df82f412` when this roadmap was written. This unit follows the 36-PR lane-stack merge in -`devlog/_plan/260913_lane_stack_merge/`, which landed the maintainer-authored +`devlog/_fin/260913_lane_stack_merge/`, which landed the maintainer-authored backlog. That batch is the precedent for the mechanism here; what changes is the authorship. Every branch in this train belongs to someone else, so attribution is a correctness requirement rather than a courtesy. diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/000_plan.md b/devlog/_fin/260913_cross_platform_desktop_app_restart/000_plan.md similarity index 100% rename from devlog/_plan/260913_cross_platform_desktop_app_restart/000_plan.md rename to devlog/_fin/260913_cross_platform_desktop_app_restart/000_plan.md diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md b/devlog/_fin/260913_cross_platform_desktop_app_restart/001_platform_topology.md similarity index 100% rename from devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md rename to devlog/_fin/260913_cross_platform_desktop_app_restart/001_platform_topology.md diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/002_audit_findings.md b/devlog/_fin/260913_cross_platform_desktop_app_restart/002_audit_findings.md similarity index 100% rename from devlog/_plan/260913_cross_platform_desktop_app_restart/002_audit_findings.md rename to devlog/_fin/260913_cross_platform_desktop_app_restart/002_audit_findings.md diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/010_phase1_shared_restart_surface.md b/devlog/_fin/260913_cross_platform_desktop_app_restart/010_phase1_shared_restart_surface.md similarity index 100% rename from devlog/_plan/260913_cross_platform_desktop_app_restart/010_phase1_shared_restart_surface.md rename to devlog/_fin/260913_cross_platform_desktop_app_restart/010_phase1_shared_restart_surface.md diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md b/devlog/_fin/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md similarity index 100% rename from devlog/_plan/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md rename to devlog/_fin/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/030_phase3_contract_merge.md b/devlog/_fin/260913_cross_platform_desktop_app_restart/030_phase3_contract_merge.md similarity index 99% rename from devlog/_plan/260913_cross_platform_desktop_app_restart/030_phase3_contract_merge.md rename to devlog/_fin/260913_cross_platform_desktop_app_restart/030_phase3_contract_merge.md index 241974a7bef..d09bae27c2e 100644 --- a/devlog/_plan/260913_cross_platform_desktop_app_restart/030_phase3_contract_merge.md +++ b/devlog/_fin/260913_cross_platform_desktop_app_restart/030_phase3_contract_merge.md @@ -25,7 +25,7 @@ quit. The operator's in-flight turn is interrupted, recovered, and interrupted a So when a desktop restart is going to run, app-servers that are **members of the discovered desktop tree** are excluded from the SIGTERM pass. Quitting the app terminates them anyway. Standalone app-servers — the npm `codex app-server` pair that -`devlog/_plan/260826_restart_codex_linux_survivor/000_repro_and_root_cause.md` +`devlog/_fin/260826_restart_codex_linux_survivor/000_repro_and_root_cause.md` documents, and SSH bootstraps — are not members and are still signalled. `afterCatalogWriteHandleAppServers` gains one option: diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/040_phase4_verification_and_delivery.md b/devlog/_fin/260913_cross_platform_desktop_app_restart/040_phase4_verification_and_delivery.md similarity index 100% rename from devlog/_plan/260913_cross_platform_desktop_app_restart/040_phase4_verification_and_delivery.md rename to devlog/_fin/260913_cross_platform_desktop_app_restart/040_phase4_verification_and_delivery.md diff --git a/devlog/_plan/260913_cross_platform_desktop_app_restart/041_execution_record.md b/devlog/_fin/260913_cross_platform_desktop_app_restart/041_execution_record.md similarity index 100% rename from devlog/_plan/260913_cross_platform_desktop_app_restart/041_execution_record.md rename to devlog/_fin/260913_cross_platform_desktop_app_restart/041_execution_record.md diff --git a/devlog/_plan/260913_devin_landing_and_caching/000_plan.md b/devlog/_fin/260913_devin_landing_and_caching/000_plan.md similarity index 99% rename from devlog/_plan/260913_devin_landing_and_caching/000_plan.md rename to devlog/_fin/260913_devin_landing_and_caching/000_plan.md index 7c91cc19590..e71adec2cdb 100644 --- a/devlog/_plan/260913_devin_landing_and_caching/000_plan.md +++ b/devlog/_fin/260913_devin_landing_and_caching/000_plan.md @@ -70,7 +70,7 @@ wp0 (이 문서) ──┬── wp1 #4420 carry (독립) | wp | 문서 | 산출물 | 쓰기 범위 | |---|---|---|---| -| wp0 | 이 문서 + 010/020/030/040 | 로드맵 | `devlog/_plan/260913_devin_landing_and_caching/` | +| wp0 | 이 문서 + 010/020/030/040 | 로드맵 | `devlog/_fin/260913_devin_landing_and_caching/` | | wp1 | `010_wp1_swe2_effort_carry.md` | carry PR → merge | `src/adapters/devin.ts`, `tests/providers/devin-adapter.test.ts`, docs/structure | | wp2 | `020_wp2_devin_cli_fixture_carry.md` | carry PR → merge | `tests/providers/devin-cli-login.test.ts` | | wp3 | `030_wp3_ttfb_generation_deadline.md` | 버그픽스 PR → merge | `src/adapters/devin/cloud-direct/chat.ts`, `src/adapters/devin.ts`, 신규 테스트 | diff --git a/devlog/_plan/260913_devin_landing_and_caching/010_wp1_swe2_effort_carry.md b/devlog/_fin/260913_devin_landing_and_caching/010_wp1_swe2_effort_carry.md similarity index 100% rename from devlog/_plan/260913_devin_landing_and_caching/010_wp1_swe2_effort_carry.md rename to devlog/_fin/260913_devin_landing_and_caching/010_wp1_swe2_effort_carry.md diff --git a/devlog/_plan/260913_devin_landing_and_caching/020_wp2_devin_cli_fixture_carry.md b/devlog/_fin/260913_devin_landing_and_caching/020_wp2_devin_cli_fixture_carry.md similarity index 100% rename from devlog/_plan/260913_devin_landing_and_caching/020_wp2_devin_cli_fixture_carry.md rename to devlog/_fin/260913_devin_landing_and_caching/020_wp2_devin_cli_fixture_carry.md diff --git a/devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md b/devlog/_fin/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md similarity index 100% rename from devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md rename to devlog/_fin/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md diff --git a/devlog/_plan/260913_devin_landing_and_caching/040_wp4_devin_prompt_cache_and_identity.md b/devlog/_fin/260913_devin_landing_and_caching/040_wp4_devin_prompt_cache_and_identity.md similarity index 100% rename from devlog/_plan/260913_devin_landing_and_caching/040_wp4_devin_prompt_cache_and_identity.md rename to devlog/_fin/260913_devin_landing_and_caching/040_wp4_devin_prompt_cache_and_identity.md diff --git a/devlog/_plan/260913_devin_landing_and_caching/050_wp5_effort_suffix_unification.md b/devlog/_fin/260913_devin_landing_and_caching/050_wp5_effort_suffix_unification.md similarity index 100% rename from devlog/_plan/260913_devin_landing_and_caching/050_wp5_effort_suffix_unification.md rename to devlog/_fin/260913_devin_landing_and_caching/050_wp5_effort_suffix_unification.md diff --git a/devlog/_plan/260913_devin_landing_and_caching/060_wp6_assign_model_router.md b/devlog/_fin/260913_devin_landing_and_caching/060_wp6_assign_model_router.md similarity index 100% rename from devlog/_plan/260913_devin_landing_and_caching/060_wp6_assign_model_router.md rename to devlog/_fin/260913_devin_landing_and_caching/060_wp6_assign_model_router.md diff --git a/devlog/_plan/260913_devin_provider_merge/000_plan.md b/devlog/_fin/260913_devin_provider_merge/000_plan.md similarity index 100% rename from devlog/_plan/260913_devin_provider_merge/000_plan.md rename to devlog/_fin/260913_devin_provider_merge/000_plan.md diff --git a/devlog/_plan/260913_devin_provider_merge/010_wp1_core_merge.md b/devlog/_fin/260913_devin_provider_merge/010_wp1_core_merge.md similarity index 100% rename from devlog/_plan/260913_devin_provider_merge/010_wp1_core_merge.md rename to devlog/_fin/260913_devin_provider_merge/010_wp1_core_merge.md diff --git a/devlog/_plan/260913_devin_provider_merge/020_wp2_migration.md b/devlog/_fin/260913_devin_provider_merge/020_wp2_migration.md similarity index 100% rename from devlog/_plan/260913_devin_provider_merge/020_wp2_migration.md rename to devlog/_fin/260913_devin_provider_merge/020_wp2_migration.md diff --git a/devlog/_plan/260913_devin_provider_merge/030_wp3_surface_sync.md b/devlog/_fin/260913_devin_provider_merge/030_wp3_surface_sync.md similarity index 100% rename from devlog/_plan/260913_devin_provider_merge/030_wp3_surface_sync.md rename to devlog/_fin/260913_devin_provider_merge/030_wp3_surface_sync.md diff --git a/devlog/_plan/260913_devin_provider_merge/040_wp4_pr.md b/devlog/_fin/260913_devin_provider_merge/040_wp4_pr.md similarity index 100% rename from devlog/_plan/260913_devin_provider_merge/040_wp4_pr.md rename to devlog/_fin/260913_devin_provider_merge/040_wp4_pr.md diff --git a/devlog/_plan/260913_godfile_round1/000_plan.md b/devlog/_fin/260913_godfile_round1/000_plan.md similarity index 100% rename from devlog/_plan/260913_godfile_round1/000_plan.md rename to devlog/_fin/260913_godfile_round1/000_plan.md diff --git a/devlog/_plan/260913_lane_stack_merge/000_plan.md b/devlog/_fin/260913_lane_stack_merge/000_plan.md similarity index 100% rename from devlog/_plan/260913_lane_stack_merge/000_plan.md rename to devlog/_fin/260913_lane_stack_merge/000_plan.md diff --git a/devlog/_plan/260913_lane_stack_merge/001_merge_topology.md b/devlog/_fin/260913_lane_stack_merge/001_merge_topology.md similarity index 100% rename from devlog/_plan/260913_lane_stack_merge/001_merge_topology.md rename to devlog/_fin/260913_lane_stack_merge/001_merge_topology.md diff --git a/devlog/_plan/260913_lane_stack_merge/002_merge_mechanics.md b/devlog/_fin/260913_lane_stack_merge/002_merge_mechanics.md similarity index 97% rename from devlog/_plan/260913_lane_stack_merge/002_merge_mechanics.md rename to devlog/_fin/260913_lane_stack_merge/002_merge_mechanics.md index 57935bee93a..861e3f6873c 100644 --- a/devlog/_plan/260913_lane_stack_merge/002_merge_mechanics.md +++ b/devlog/_fin/260913_lane_stack_merge/002_merge_mechanics.md @@ -53,7 +53,7 @@ dashboard, not running `bun run test`. Images need a hosted URL to render in a description, so the PNGs are committed into the lane's devlog unit under `screenshots/` and referenced by raw link. -`devlog/_plan/260912_audio_apis_stack/screenshots/` is the existing precedent. +`devlog/_fin/260912_audio_apis_stack/screenshots/` is the existing precedent. The screenshot commit rides along with the tip's final `origin/dev` re-merge so the lane pays for one CI run rather than one per commit. diff --git a/devlog/_plan/260913_lane_stack_merge/010_wave1.md b/devlog/_fin/260913_lane_stack_merge/010_wave1.md similarity index 98% rename from devlog/_plan/260913_lane_stack_merge/010_wave1.md rename to devlog/_fin/260913_lane_stack_merge/010_wave1.md index dc49e84c851..021aec6c19e 100644 --- a/devlog/_plan/260913_lane_stack_merge/010_wave1.md +++ b/devlog/_fin/260913_lane_stack_merge/010_wave1.md @@ -13,7 +13,7 @@ Each runs as its own worktree-backed thread owned by `xai/grok-4.6`. | 2 | #4392 | `codex/audio-streaming` | skip | | 3 | #4395 | `codex/audio-connections` | tip, full run | -Internal order is fixed by `devlog/_plan/260912_audio_apis_stack`: 010 +Internal order is fixed by `devlog/_fin/260912_audio_apis_stack`: 010 transcription, 020 streaming, 030 dashboard. #4391 and #4392 share `src/providers/openai-sidecar.ts`, `src/server/audio-upstream.ts`, and `src/server/live.ts`, so reordering them guarantees conflicts. diff --git a/devlog/_plan/260913_lane_stack_merge/020_wave2.md b/devlog/_fin/260913_lane_stack_merge/020_wave2.md similarity index 100% rename from devlog/_plan/260913_lane_stack_merge/020_wave2.md rename to devlog/_fin/260913_lane_stack_merge/020_wave2.md diff --git a/devlog/_plan/260913_lane_stack_merge/030_tip_and_regression.md b/devlog/_fin/260913_lane_stack_merge/030_tip_and_regression.md similarity index 100% rename from devlog/_plan/260913_lane_stack_merge/030_tip_and_regression.md rename to devlog/_fin/260913_lane_stack_merge/030_tip_and_regression.md diff --git a/devlog/_plan/260913_lane_stack_merge/040_issue_closure.md b/devlog/_fin/260913_lane_stack_merge/040_issue_closure.md similarity index 100% rename from devlog/_plan/260913_lane_stack_merge/040_issue_closure.md rename to devlog/_fin/260913_lane_stack_merge/040_issue_closure.md diff --git a/devlog/_plan/260913_lane_stack_merge/050_outcome.md b/devlog/_fin/260913_lane_stack_merge/050_outcome.md similarity index 100% rename from devlog/_plan/260913_lane_stack_merge/050_outcome.md rename to devlog/_fin/260913_lane_stack_merge/050_outcome.md diff --git a/devlog/_plan/260913_lane_stack_merge/screenshots/codex-account-pool.png b/devlog/_fin/260913_lane_stack_merge/screenshots/codex-account-pool.png similarity index 100% rename from devlog/_plan/260913_lane_stack_merge/screenshots/codex-account-pool.png rename to devlog/_fin/260913_lane_stack_merge/screenshots/codex-account-pool.png diff --git a/devlog/_plan/260913_lane_stack_merge/screenshots/dashboard-overview.png b/devlog/_fin/260913_lane_stack_merge/screenshots/dashboard-overview.png similarity index 100% rename from devlog/_plan/260913_lane_stack_merge/screenshots/dashboard-overview.png rename to devlog/_fin/260913_lane_stack_merge/screenshots/dashboard-overview.png diff --git a/devlog/_plan/260913_lane_stack_merge/screenshots/integrations-clients.png b/devlog/_fin/260913_lane_stack_merge/screenshots/integrations-clients.png similarity index 100% rename from devlog/_plan/260913_lane_stack_merge/screenshots/integrations-clients.png rename to devlog/_fin/260913_lane_stack_merge/screenshots/integrations-clients.png diff --git a/devlog/_plan/260913_lane_stack_merge/screenshots/remote-workspace.png b/devlog/_fin/260913_lane_stack_merge/screenshots/remote-workspace.png similarity index 100% rename from devlog/_plan/260913_lane_stack_merge/screenshots/remote-workspace.png rename to devlog/_fin/260913_lane_stack_merge/screenshots/remote-workspace.png diff --git a/devlog/_plan/260913_missing_coauthor_credits/000_plan.md b/devlog/_fin/260913_missing_coauthor_credits/000_plan.md similarity index 98% rename from devlog/_plan/260913_missing_coauthor_credits/000_plan.md rename to devlog/_fin/260913_missing_coauthor_credits/000_plan.md index 97c78e5d348..c8725127833 100644 --- a/devlog/_plan/260913_missing_coauthor_credits/000_plan.md +++ b/devlog/_fin/260913_missing_coauthor_credits/000_plan.md @@ -31,7 +31,7 @@ on the page: **#3988 by @rrmlima**, merged through **#4031**. - **Stop condition:** #3988 is on `CREDITS.md`, the PR is merged to `dev`, or NOOP if a later origin/dev commit already repaired it. - **Memory artifact:** this unit - `devlog/_plan/260913_missing_coauthor_credits/` plus + `devlog/_fin/260913_missing_coauthor_credits/` plus `.codexclaw/evidence/4c876c16-4cd9-4a26-bcd5-743ccaa1b137/credits-scan/`. - **Expected terminal outcomes:** DONE (row + merged PR); NOOP (already repaired); BLOCKED (merge protection); UNSAFE (history rewrite / raw email in tree). diff --git a/devlog/_plan/260913_missing_coauthor_credits/001_scan_evidence.md b/devlog/_fin/260913_missing_coauthor_credits/001_scan_evidence.md similarity index 100% rename from devlog/_plan/260913_missing_coauthor_credits/001_scan_evidence.md rename to devlog/_fin/260913_missing_coauthor_credits/001_scan_evidence.md diff --git a/devlog/_plan/260913_missing_coauthor_credits/010_inventory_freeze.md b/devlog/_fin/260913_missing_coauthor_credits/010_inventory_freeze.md similarity index 100% rename from devlog/_plan/260913_missing_coauthor_credits/010_inventory_freeze.md rename to devlog/_fin/260913_missing_coauthor_credits/010_inventory_freeze.md diff --git a/devlog/_plan/260913_missing_coauthor_credits/020_credits_update.md b/devlog/_fin/260913_missing_coauthor_credits/020_credits_update.md similarity index 100% rename from devlog/_plan/260913_missing_coauthor_credits/020_credits_update.md rename to devlog/_fin/260913_missing_coauthor_credits/020_credits_update.md diff --git a/devlog/_plan/260913_missing_coauthor_credits/030_pr_merge.md b/devlog/_fin/260913_missing_coauthor_credits/030_pr_merge.md similarity index 100% rename from devlog/_plan/260913_missing_coauthor_credits/030_pr_merge.md rename to devlog/_fin/260913_missing_coauthor_credits/030_pr_merge.md diff --git a/devlog/_plan/260913_model_availability_errors/000_summary.md b/devlog/_fin/260913_model_availability_errors/000_summary.md similarity index 100% rename from devlog/_plan/260913_model_availability_errors/000_summary.md rename to devlog/_fin/260913_model_availability_errors/000_summary.md diff --git a/devlog/_plan/260913_model_picker_grouping_and_effort/000_plan.md b/devlog/_fin/260913_model_picker_grouping_and_effort/000_plan.md similarity index 100% rename from devlog/_plan/260913_model_picker_grouping_and_effort/000_plan.md rename to devlog/_fin/260913_model_picker_grouping_and_effort/000_plan.md diff --git a/devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md b/devlog/_fin/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md similarity index 100% rename from devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md rename to devlog/_fin/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md diff --git a/devlog/_plan/260914_carry_pr4528/000_plan.md b/devlog/_fin/260914_carry_pr4528/000_plan.md similarity index 100% rename from devlog/_plan/260914_carry_pr4528/000_plan.md rename to devlog/_fin/260914_carry_pr4528/000_plan.md diff --git a/devlog/_plan/260914_carry_pr4528/010_phase1.md b/devlog/_fin/260914_carry_pr4528/010_phase1.md similarity index 100% rename from devlog/_plan/260914_carry_pr4528/010_phase1.md rename to devlog/_fin/260914_carry_pr4528/010_phase1.md diff --git a/devlog/_plan/260914_codex_history_preflight_scope/000_plan.md b/devlog/_fin/260914_codex_history_preflight_scope/000_plan.md similarity index 100% rename from devlog/_plan/260914_codex_history_preflight_scope/000_plan.md rename to devlog/_fin/260914_codex_history_preflight_scope/000_plan.md diff --git a/devlog/_plan/260914_codex_history_preflight_scope/010_rootcause_evidence.md b/devlog/_fin/260914_codex_history_preflight_scope/010_rootcause_evidence.md similarity index 100% rename from devlog/_plan/260914_codex_history_preflight_scope/010_rootcause_evidence.md rename to devlog/_fin/260914_codex_history_preflight_scope/010_rootcause_evidence.md diff --git a/devlog/_plan/260914_codex_history_preflight_scope/020_fix_and_contract_change.md b/devlog/_fin/260914_codex_history_preflight_scope/020_fix_and_contract_change.md similarity index 100% rename from devlog/_plan/260914_codex_history_preflight_scope/020_fix_and_contract_change.md rename to devlog/_fin/260914_codex_history_preflight_scope/020_fix_and_contract_change.md diff --git a/devlog/_plan/260914_cost_guard_stabilization/000_unit.md b/devlog/_fin/260914_cost_guard_stabilization/000_unit.md similarity index 98% rename from devlog/_plan/260914_cost_guard_stabilization/000_unit.md rename to devlog/_fin/260914_cost_guard_stabilization/000_unit.md index 87c6cffe368..a14fcce0662 100644 --- a/devlog/_plan/260914_cost_guard_stabilization/000_unit.md +++ b/devlog/_fin/260914_cost_guard_stabilization/000_unit.md @@ -65,7 +65,7 @@ routing mistake into a cost event; they ship after the incident is closed. ## Relationship to #4581 -The L2 lane unit `devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md` +The L2 lane unit `devlog/_fin/260914_l2_pool_routing_cache/010_cache_safe_rebind.md` reached the headroom floor by a different route -- keep the threshold eviction rule, constrain the destination -- and landed on `dev` as #4581 while this unit was in flight. That analysis is correct and this work **builds on it** rather than beside diff --git a/devlog/_plan/260914_cost_guard_stabilization/010_bound_binding_policy.md b/devlog/_fin/260914_cost_guard_stabilization/010_bound_binding_policy.md similarity index 100% rename from devlog/_plan/260914_cost_guard_stabilization/010_bound_binding_policy.md rename to devlog/_fin/260914_cost_guard_stabilization/010_bound_binding_policy.md diff --git a/devlog/_plan/260914_cost_guard_stabilization/020_backoff_preserves_binding.md b/devlog/_fin/260914_cost_guard_stabilization/020_backoff_preserves_binding.md similarity index 100% rename from devlog/_plan/260914_cost_guard_stabilization/020_backoff_preserves_binding.md rename to devlog/_fin/260914_cost_guard_stabilization/020_backoff_preserves_binding.md diff --git a/devlog/_plan/260914_cost_guard_stabilization/030_move_reason_evidence.md b/devlog/_fin/260914_cost_guard_stabilization/030_move_reason_evidence.md similarity index 100% rename from devlog/_plan/260914_cost_guard_stabilization/030_move_reason_evidence.md rename to devlog/_fin/260914_cost_guard_stabilization/030_move_reason_evidence.md diff --git a/devlog/_plan/260914_cost_guard_stabilization/040_send_budget.md b/devlog/_fin/260914_cost_guard_stabilization/040_send_budget.md similarity index 100% rename from devlog/_plan/260914_cost_guard_stabilization/040_send_budget.md rename to devlog/_fin/260914_cost_guard_stabilization/040_send_budget.md diff --git a/devlog/_plan/260914_cost_guard_stabilization/050_worker_isolation.md b/devlog/_fin/260914_cost_guard_stabilization/050_worker_isolation.md similarity index 100% rename from devlog/_plan/260914_cost_guard_stabilization/050_worker_isolation.md rename to devlog/_fin/260914_cost_guard_stabilization/050_worker_isolation.md diff --git a/devlog/_plan/260914_cost_guard_stabilization/060_quota_cache_domains.md b/devlog/_fin/260914_cost_guard_stabilization/060_quota_cache_domains.md similarity index 100% rename from devlog/_plan/260914_cost_guard_stabilization/060_quota_cache_domains.md rename to devlog/_fin/260914_cost_guard_stabilization/060_quota_cache_domains.md diff --git a/devlog/_plan/260914_cost_guard_stabilization/070_delivery.md b/devlog/_fin/260914_cost_guard_stabilization/070_delivery.md similarity index 100% rename from devlog/_plan/260914_cost_guard_stabilization/070_delivery.md rename to devlog/_fin/260914_cost_guard_stabilization/070_delivery.md diff --git a/devlog/_plan/260914_cost_guard_stabilization/080_codex_cache_reinforcement.md b/devlog/_fin/260914_cost_guard_stabilization/080_codex_cache_reinforcement.md similarity index 100% rename from devlog/_plan/260914_cost_guard_stabilization/080_codex_cache_reinforcement.md rename to devlog/_fin/260914_cost_guard_stabilization/080_codex_cache_reinforcement.md diff --git a/devlog/_plan/260914_cost_guard_stabilization/090_remaining_stack.md b/devlog/_fin/260914_cost_guard_stabilization/090_remaining_stack.md similarity index 100% rename from devlog/_plan/260914_cost_guard_stabilization/090_remaining_stack.md rename to devlog/_fin/260914_cost_guard_stabilization/090_remaining_stack.md diff --git a/devlog/_plan/260914_cost_guard_stabilization/100_dev_tip_closure_assessment.md b/devlog/_fin/260914_cost_guard_stabilization/100_dev_tip_closure_assessment.md similarity index 100% rename from devlog/_plan/260914_cost_guard_stabilization/100_dev_tip_closure_assessment.md rename to devlog/_fin/260914_cost_guard_stabilization/100_dev_tip_closure_assessment.md diff --git a/devlog/_plan/260914_cost_guard_stabilization/110_spend_ceiling_configuration.md b/devlog/_fin/260914_cost_guard_stabilization/110_spend_ceiling_configuration.md similarity index 100% rename from devlog/_plan/260914_cost_guard_stabilization/110_spend_ceiling_configuration.md rename to devlog/_fin/260914_cost_guard_stabilization/110_spend_ceiling_configuration.md diff --git a/devlog/_plan/260914_godfile_round2/000_plan.md b/devlog/_fin/260914_godfile_round2/000_plan.md similarity index 98% rename from devlog/_plan/260914_godfile_round2/000_plan.md rename to devlog/_fin/260914_godfile_round2/000_plan.md index ffe4ed88bf2..2c323314153 100644 --- a/devlog/_plan/260914_godfile_round2/000_plan.md +++ b/devlog/_fin/260914_godfile_round2/000_plan.md @@ -14,7 +14,7 @@ | Non-goals | 기능 정책 변경, 버그 수정 동반, `tests/` 33개 분해, i18n·생성물·devlog 분해, `core.ts`·`server/index.ts`·`auth-api.ts`·`providers/registry.ts` 본체(별도 단위) | | Verifier | hosted CI. 수동 브랜치 체인의 tip PR 실행을 레인 게이트로 쓴다(DEV-STACK-08, owner 승인) | | Stop condition | 6개 사이클의 D가 모두 닫히고 레인이 `dev`에 머지될 때 | -| Memory artifact | 이 단위(`devlog/_plan/260914_godfile_round2/`)와 각 PR 본문 | +| Memory artifact | 이 단위(`devlog/_fin/260914_godfile_round2/`)와 각 PR 본문 | | Expected outcomes | 성공 = 7파일 facade화 + 래칫 녹색 / 차단 = tip CI 적색이 반복되고 원인이 분해 외부일 때 | | Escalation | tip CI가 분해와 무관한 이유로 적색이거나, 래칫 기준선이 다른 작업과 충돌할 때 | diff --git a/devlog/_plan/260914_godfile_round2/010_phase1_file_size_ratchet.md b/devlog/_fin/260914_godfile_round2/010_phase1_file_size_ratchet.md similarity index 99% rename from devlog/_plan/260914_godfile_round2/010_phase1_file_size_ratchet.md rename to devlog/_fin/260914_godfile_round2/010_phase1_file_size_ratchet.md index c073f95f795..f48eef637dc 100644 --- a/devlog/_plan/260914_godfile_round2/010_phase1_file_size_ratchet.md +++ b/devlog/_fin/260914_godfile_round2/010_phase1_file_size_ratchet.md @@ -638,7 +638,7 @@ describe("file-size ratchet: scan filter", () => { // Prefix and exact exclusions. gui/dist without a trailing slash is listed // in the contract alongside gui/dist/ children. - expect(isScannedPath("devlog/_plan/260914_godfile_round2/010.md")).toBe(false); + expect(isScannedPath("devlog/_fin/260914_godfile_round2/010.md")).toBe(false); expect(isScannedPath("assets/banner.png")).toBe(false); expect(isScannedPath("docs-site/public/favicon.png")).toBe(false); expect(isScannedPath("docs-site/src/assets/og.png")).toBe(false); diff --git a/devlog/_plan/260914_godfile_round2/020_phase2_state_and_shim.md b/devlog/_fin/260914_godfile_round2/020_phase2_state_and_shim.md similarity index 100% rename from devlog/_plan/260914_godfile_round2/020_phase2_state_and_shim.md rename to devlog/_fin/260914_godfile_round2/020_phase2_state_and_shim.md diff --git a/devlog/_plan/260914_godfile_round2/030_phase3_inject_and_catalog_sync.md b/devlog/_fin/260914_godfile_round2/030_phase3_inject_and_catalog_sync.md similarity index 100% rename from devlog/_plan/260914_godfile_round2/030_phase3_inject_and_catalog_sync.md rename to devlog/_fin/260914_godfile_round2/030_phase3_inject_and_catalog_sync.md diff --git a/devlog/_plan/260914_godfile_round2/040_phase4_routing_and_quota.md b/devlog/_fin/260914_godfile_round2/040_phase4_routing_and_quota.md similarity index 100% rename from devlog/_plan/260914_godfile_round2/040_phase4_routing_and_quota.md rename to devlog/_fin/260914_godfile_round2/040_phase4_routing_and_quota.md diff --git a/devlog/_plan/260914_godfile_round2/050_phase5_config.md b/devlog/_fin/260914_godfile_round2/050_phase5_config.md similarity index 100% rename from devlog/_plan/260914_godfile_round2/050_phase5_config.md rename to devlog/_fin/260914_godfile_round2/050_phase5_config.md diff --git a/devlog/_plan/260914_l2_pool_routing_cache/000_unit.md b/devlog/_fin/260914_l2_pool_routing_cache/000_unit.md similarity index 100% rename from devlog/_plan/260914_l2_pool_routing_cache/000_unit.md rename to devlog/_fin/260914_l2_pool_routing_cache/000_unit.md diff --git a/devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md b/devlog/_fin/260914_l2_pool_routing_cache/010_cache_safe_rebind.md similarity index 100% rename from devlog/_plan/260914_l2_pool_routing_cache/010_cache_safe_rebind.md rename to devlog/_fin/260914_l2_pool_routing_cache/010_cache_safe_rebind.md diff --git a/devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md b/devlog/_fin/260914_l2_pool_routing_cache/020_routing_adoption.md similarity index 100% rename from devlog/_plan/260914_l2_pool_routing_cache/020_routing_adoption.md rename to devlog/_fin/260914_l2_pool_routing_cache/020_routing_adoption.md diff --git a/devlog/_plan/260914_l2_pool_routing_cache/030_delivery.md b/devlog/_fin/260914_l2_pool_routing_cache/030_delivery.md similarity index 100% rename from devlog/_plan/260914_l2_pool_routing_cache/030_delivery.md rename to devlog/_fin/260914_l2_pool_routing_cache/030_delivery.md diff --git a/devlog/_plan/260914_l4_responses_media/010_roadmap.md b/devlog/_fin/260914_l4_responses_media/010_roadmap.md similarity index 100% rename from devlog/_plan/260914_l4_responses_media/010_roadmap.md rename to devlog/_fin/260914_l4_responses_media/010_roadmap.md diff --git a/devlog/_plan/260914_l5_provider_account_edges/000_plan.md b/devlog/_fin/260914_l5_provider_account_edges/000_plan.md similarity index 100% rename from devlog/_plan/260914_l5_provider_account_edges/000_plan.md rename to devlog/_fin/260914_l5_provider_account_edges/000_plan.md diff --git a/devlog/_plan/260914_l5_provider_account_edges/010_wp1_account_lifecycle_edges.md b/devlog/_fin/260914_l5_provider_account_edges/010_wp1_account_lifecycle_edges.md similarity index 100% rename from devlog/_plan/260914_l5_provider_account_edges/010_wp1_account_lifecycle_edges.md rename to devlog/_fin/260914_l5_provider_account_edges/010_wp1_account_lifecycle_edges.md diff --git a/devlog/_plan/260914_l7_web_search_bridge/000_plan.md b/devlog/_fin/260914_l7_web_search_bridge/000_plan.md similarity index 100% rename from devlog/_plan/260914_l7_web_search_bridge/000_plan.md rename to devlog/_fin/260914_l7_web_search_bridge/000_plan.md diff --git a/devlog/_plan/260914_l7_web_search_bridge/010_mixed_tool_continuation.md b/devlog/_fin/260914_l7_web_search_bridge/010_mixed_tool_continuation.md similarity index 100% rename from devlog/_plan/260914_l7_web_search_bridge/010_mixed_tool_continuation.md rename to devlog/_fin/260914_l7_web_search_bridge/010_mixed_tool_continuation.md diff --git a/devlog/_plan/260914_l7_web_search_bridge/020_alpha_search_fallback.md b/devlog/_fin/260914_l7_web_search_bridge/020_alpha_search_fallback.md similarity index 100% rename from devlog/_plan/260914_l7_web_search_bridge/020_alpha_search_fallback.md rename to devlog/_fin/260914_l7_web_search_bridge/020_alpha_search_fallback.md diff --git a/devlog/_plan/260914_provider_parity_stack/000_plan.md b/devlog/_fin/260914_provider_parity_stack/000_plan.md similarity index 100% rename from devlog/_plan/260914_provider_parity_stack/000_plan.md rename to devlog/_fin/260914_provider_parity_stack/000_plan.md diff --git a/devlog/_plan/260914_provider_parity_stack/001_audit_evidence.md b/devlog/_fin/260914_provider_parity_stack/001_audit_evidence.md similarity index 100% rename from devlog/_plan/260914_provider_parity_stack/001_audit_evidence.md rename to devlog/_fin/260914_provider_parity_stack/001_audit_evidence.md diff --git a/devlog/_plan/260914_provider_parity_stack/002_architect_dispositions.md b/devlog/_fin/260914_provider_parity_stack/002_architect_dispositions.md similarity index 100% rename from devlog/_plan/260914_provider_parity_stack/002_architect_dispositions.md rename to devlog/_fin/260914_provider_parity_stack/002_architect_dispositions.md diff --git a/devlog/_plan/260914_provider_parity_stack/003_blocker_corrections.md b/devlog/_fin/260914_provider_parity_stack/003_blocker_corrections.md similarity index 100% rename from devlog/_plan/260914_provider_parity_stack/003_blocker_corrections.md rename to devlog/_fin/260914_provider_parity_stack/003_blocker_corrections.md diff --git a/devlog/_plan/260914_provider_parity_stack/010_phase1_ingress_normalization.md b/devlog/_fin/260914_provider_parity_stack/010_phase1_ingress_normalization.md similarity index 100% rename from devlog/_plan/260914_provider_parity_stack/010_phase1_ingress_normalization.md rename to devlog/_fin/260914_provider_parity_stack/010_phase1_ingress_normalization.md diff --git a/devlog/_plan/260914_provider_parity_stack/020_phase2_chat_responses_controls.md b/devlog/_fin/260914_provider_parity_stack/020_phase2_chat_responses_controls.md similarity index 100% rename from devlog/_plan/260914_provider_parity_stack/020_phase2_chat_responses_controls.md rename to devlog/_fin/260914_provider_parity_stack/020_phase2_chat_responses_controls.md diff --git a/devlog/_plan/260914_provider_parity_stack/030_phase3_provider_wire_contracts.md b/devlog/_fin/260914_provider_parity_stack/030_phase3_provider_wire_contracts.md similarity index 100% rename from devlog/_plan/260914_provider_parity_stack/030_phase3_provider_wire_contracts.md rename to devlog/_fin/260914_provider_parity_stack/030_phase3_provider_wire_contracts.md diff --git a/devlog/_plan/260914_provider_parity_stack/040_phase4_modality_fidelity.md b/devlog/_fin/260914_provider_parity_stack/040_phase4_modality_fidelity.md similarity index 100% rename from devlog/_plan/260914_provider_parity_stack/040_phase4_modality_fidelity.md rename to devlog/_fin/260914_provider_parity_stack/040_phase4_modality_fidelity.md diff --git a/devlog/_plan/260914_provider_parity_stack/050_residuals.md b/devlog/_fin/260914_provider_parity_stack/050_residuals.md similarity index 100% rename from devlog/_plan/260914_provider_parity_stack/050_residuals.md rename to devlog/_fin/260914_provider_parity_stack/050_residuals.md diff --git a/devlog/_plan/260914_provider_parity_stack/060_direct_media_guard.md b/devlog/_fin/260914_provider_parity_stack/060_direct_media_guard.md similarity index 100% rename from devlog/_plan/260914_provider_parity_stack/060_direct_media_guard.md rename to devlog/_fin/260914_provider_parity_stack/060_direct_media_guard.md diff --git a/devlog/_plan/260914_r2l8_catalog_autorefresh/010_roadmap.md b/devlog/_fin/260914_r2l8_catalog_autorefresh/010_roadmap.md similarity index 100% rename from devlog/_plan/260914_r2l8_catalog_autorefresh/010_roadmap.md rename to devlog/_fin/260914_r2l8_catalog_autorefresh/010_roadmap.md diff --git a/devlog/_plan/260914_r2l8_catalog_autorefresh/020_issue_3377_capability_audit.md b/devlog/_fin/260914_r2l8_catalog_autorefresh/020_issue_3377_capability_audit.md similarity index 100% rename from devlog/_plan/260914_r2l8_catalog_autorefresh/020_issue_3377_capability_audit.md rename to devlog/_fin/260914_r2l8_catalog_autorefresh/020_issue_3377_capability_audit.md diff --git a/devlog/_plan/260914_regression_audit_and_release/000_audit_and_release_plan.md b/devlog/_fin/260914_regression_audit_and_release/000_audit_and_release_plan.md similarity index 100% rename from devlog/_plan/260914_regression_audit_and_release/000_audit_and_release_plan.md rename to devlog/_fin/260914_regression_audit_and_release/000_audit_and_release_plan.md diff --git a/devlog/_plan/260914_regression_audit_and_release/010_audit_findings.md b/devlog/_fin/260914_regression_audit_and_release/010_audit_findings.md similarity index 100% rename from devlog/_plan/260914_regression_audit_and_release/010_audit_findings.md rename to devlog/_fin/260914_regression_audit_and_release/010_audit_findings.md diff --git a/devlog/_plan/260914_regression_audit_and_release/020_release_runbook.md b/devlog/_fin/260914_regression_audit_and_release/020_release_runbook.md similarity index 100% rename from devlog/_plan/260914_regression_audit_and_release/020_release_runbook.md rename to devlog/_fin/260914_regression_audit_and_release/020_release_runbook.md diff --git a/devlog/_plan/260915_2560_release_train/000_roadmap.md b/devlog/_fin/260915_2560_release_train/000_roadmap.md similarity index 100% rename from devlog/_plan/260915_2560_release_train/000_roadmap.md rename to devlog/_fin/260915_2560_release_train/000_roadmap.md diff --git a/devlog/_plan/260915_2560_release_train/010_land_4683.md b/devlog/_fin/260915_2560_release_train/010_land_4683.md similarity index 100% rename from devlog/_plan/260915_2560_release_train/010_land_4683.md rename to devlog/_fin/260915_2560_release_train/010_land_4683.md diff --git a/devlog/_plan/260915_2560_release_train/020_regression_audit.md b/devlog/_fin/260915_2560_release_train/020_regression_audit.md similarity index 100% rename from devlog/_plan/260915_2560_release_train/020_regression_audit.md rename to devlog/_fin/260915_2560_release_train/020_regression_audit.md diff --git a/devlog/_plan/260915_2560_release_train/030_release.md b/devlog/_fin/260915_2560_release_train/030_release.md similarity index 100% rename from devlog/_plan/260915_2560_release_train/030_release.md rename to devlog/_fin/260915_2560_release_train/030_release.md diff --git a/devlog/_plan/260915_2560_release_train/040_release_decision.md b/devlog/_fin/260915_2560_release_train/040_release_decision.md similarity index 100% rename from devlog/_plan/260915_2560_release_train/040_release_decision.md rename to devlog/_fin/260915_2560_release_train/040_release_decision.md diff --git a/devlog/_plan/260915_godfile_round3/000_plan.md b/devlog/_fin/260915_godfile_round3/000_plan.md similarity index 100% rename from devlog/_plan/260915_godfile_round3/000_plan.md rename to devlog/_fin/260915_godfile_round3/000_plan.md diff --git a/devlog/_plan/260915_godfile_round3/010_phase1_config.md b/devlog/_fin/260915_godfile_round3/010_phase1_config.md similarity index 97% rename from devlog/_plan/260915_godfile_round3/010_phase1_config.md rename to devlog/_fin/260915_godfile_round3/010_phase1_config.md index 5690230a157..0ebaf4ae601 100644 --- a/devlog/_plan/260915_godfile_round3/010_phase1_config.md +++ b/devlog/_fin/260915_godfile_round3/010_phase1_config.md @@ -1,6 +1,6 @@ # 010 — src/config.ts 파사드 분해 (260914 050 대체 최신판) -src/config.ts 4,799줄(기준 트리 ce0ac617da)이 스키마·로드 열화·salvage·잠금·치환 쓰기·라이브 재결합을 한 파일에 들고 있어 래칫 이후에도 2,000줄을 넘긴다. 이 문서는 `devlog/_plan/260914_godfile_round2/050_phase5_config.md`를 대체하는 복붙 가능한 이동 계약이다. 그 라운드가 dev에서 이 파일에 +92줄(#4546/#4624 credentialGroups)을 더했으므로 모든 원본 행 번호를 이 트리에서 다시 잡았다. 구현자는 아래 원본 행을 새 리프로 옮기고 파사드가 기존 export 이름을 그대로 다시보내며, 소비자는 import 경로를 건드리지 않는다. create-only 경로 initializePersistedConfigIfMissing와 치환 경로 saveConfig는 공용 헬퍼로 합치지 않고 잔여 파사드에 함께 남기고, 경고 메모 세 값은 warn-memo 단일 소유 모듈로 먼저 분리하며, configSchema는 키 그룹으로 쪼개지 않는다. PR 순서는 실제 의존(salvage→schema, diagnostics→salvage/load-degrade, live-reconcile→persist)을 따라 warn-memo·독립 잎 → schema → salvage+load-degrade → mutation-lock+persist-unlocked+diagnostics → live-reconcile로 고정했다. +src/config.ts 4,799줄(기준 트리 ce0ac617da)이 스키마·로드 열화·salvage·잠금·치환 쓰기·라이브 재결합을 한 파일에 들고 있어 래칫 이후에도 2,000줄을 넘긴다. 이 문서는 `devlog/_fin/260914_godfile_round2/050_phase5_config.md`를 대체하는 복붙 가능한 이동 계약이다. 그 라운드가 dev에서 이 파일에 +92줄(#4546/#4624 credentialGroups)을 더했으므로 모든 원본 행 번호를 이 트리에서 다시 잡았다. 구현자는 아래 원본 행을 새 리프로 옮기고 파사드가 기존 export 이름을 그대로 다시보내며, 소비자는 import 경로를 건드리지 않는다. create-only 경로 initializePersistedConfigIfMissing와 치환 경로 saveConfig는 공용 헬퍼로 합치지 않고 잔여 파사드에 함께 남기고, 경고 메모 세 값은 warn-memo 단일 소유 모듈로 먼저 분리하며, configSchema는 키 그룹으로 쪼개지 않는다. PR 순서는 실제 의존(salvage→schema, diagnostics→salvage/load-degrade, live-reconcile→persist)을 따라 warn-memo·독립 잎 → schema → salvage+load-degrade → mutation-lock+persist-unlocked+diagnostics → live-reconcile로 고정했다. > 전달 형태 정정: 이 문서가 적은 브랜치 이름과 PR 개수는 실행되지 않았다. 다섯 파일이 한 워킹트리에서 동시에 작업돼 두 개의 PR로 수렴했다. 이동 계약과 함정 항목은 그대로 실행됐다. 실제 전달은 [090_outcome.md](./090_outcome.md) 를 보라. diff --git a/devlog/_plan/260915_godfile_round3/020_phase2_providers_registry.md b/devlog/_fin/260915_godfile_round3/020_phase2_providers_registry.md similarity index 100% rename from devlog/_plan/260915_godfile_round3/020_phase2_providers_registry.md rename to devlog/_fin/260915_godfile_round3/020_phase2_providers_registry.md diff --git a/devlog/_plan/260915_godfile_round3/030_phase3_codex_auth_api.md b/devlog/_fin/260915_godfile_round3/030_phase3_codex_auth_api.md similarity index 100% rename from devlog/_plan/260915_godfile_round3/030_phase3_codex_auth_api.md rename to devlog/_fin/260915_godfile_round3/030_phase3_codex_auth_api.md diff --git a/devlog/_plan/260915_godfile_round3/040_phase4_catalog_provider_fetch.md b/devlog/_fin/260915_godfile_round3/040_phase4_catalog_provider_fetch.md similarity index 100% rename from devlog/_plan/260915_godfile_round3/040_phase4_catalog_provider_fetch.md rename to devlog/_fin/260915_godfile_round3/040_phase4_catalog_provider_fetch.md diff --git a/devlog/_plan/260915_godfile_round3/050_phase5_adapters_openai_chat.md b/devlog/_fin/260915_godfile_round3/050_phase5_adapters_openai_chat.md similarity index 100% rename from devlog/_plan/260915_godfile_round3/050_phase5_adapters_openai_chat.md rename to devlog/_fin/260915_godfile_round3/050_phase5_adapters_openai_chat.md diff --git a/devlog/_plan/260915_godfile_round3/090_outcome.md b/devlog/_fin/260915_godfile_round3/090_outcome.md similarity index 100% rename from devlog/_plan/260915_godfile_round3/090_outcome.md rename to devlog/_fin/260915_godfile_round3/090_outcome.md diff --git a/devlog/_plan/260915_godfile_round5/000_plan.md b/devlog/_fin/260915_godfile_round5/000_plan.md similarity index 100% rename from devlog/_plan/260915_godfile_round5/000_plan.md rename to devlog/_fin/260915_godfile_round5/000_plan.md diff --git a/devlog/_plan/260915_godfile_round5/010_openai_responses.md b/devlog/_fin/260915_godfile_round5/010_openai_responses.md similarity index 100% rename from devlog/_plan/260915_godfile_round5/010_openai_responses.md rename to devlog/_fin/260915_godfile_round5/010_openai_responses.md diff --git a/devlog/_plan/260915_godfile_round5/020_bridge.md b/devlog/_fin/260915_godfile_round5/020_bridge.md similarity index 100% rename from devlog/_plan/260915_godfile_round5/020_bridge.md rename to devlog/_fin/260915_godfile_round5/020_bridge.md diff --git a/devlog/_plan/260915_godfile_round5/030_activation_guard.md b/devlog/_fin/260915_godfile_round5/030_activation_guard.md similarity index 100% rename from devlog/_plan/260915_godfile_round5/030_activation_guard.md rename to devlog/_fin/260915_godfile_round5/030_activation_guard.md diff --git a/devlog/_plan/260915_godfile_round5/040_server_index.md b/devlog/_fin/260915_godfile_round5/040_server_index.md similarity index 99% rename from devlog/_plan/260915_godfile_round5/040_server_index.md rename to devlog/_fin/260915_godfile_round5/040_server_index.md index 71d58bcad36..3e0e8938fd0 100644 --- a/devlog/_plan/260915_godfile_round5/040_server_index.md +++ b/devlog/_fin/260915_godfile_round5/040_server_index.md @@ -184,5 +184,5 @@ sed -n '<구간>p' src/server/index.ts # 1-91, 92-101, 199-266, 267-296, 338-3 sed -n '1481,3220p' src/server/index.ts | rg -o -w '' | sort | uniq -c # serveOptions 캡처 sed -n '2976,3219p' src/server/index.ts | rg -o -w '<동일 목록>' | sort | uniq -c # websocket 캡처 sed -n '1481,3220p' src/server/index.ts | rg -n -w 'server' # 라이브 바인딩 위치 -ls src/server/ ; ls -la devlog/_plan/260915_godfile_round5/ +ls src/server/ ; ls -la devlog/_fin/260915_godfile_round5/ ``` diff --git a/devlog/_plan/260915_godfile_round5/050_stack_and_gates.md b/devlog/_fin/260915_godfile_round5/050_stack_and_gates.md similarity index 100% rename from devlog/_plan/260915_godfile_round5/050_stack_and_gates.md rename to devlog/_fin/260915_godfile_round5/050_stack_and_gates.md diff --git a/devlog/_plan/260915_godfile_round5/060_audit_record.md b/devlog/_fin/260915_godfile_round5/060_audit_record.md similarity index 100% rename from devlog/_plan/260915_godfile_round5/060_audit_record.md rename to devlog/_fin/260915_godfile_round5/060_audit_record.md diff --git a/devlog/_plan/260915_godfile_round5/061_server_index_outcome.md b/devlog/_fin/260915_godfile_round5/061_server_index_outcome.md similarity index 100% rename from devlog/_plan/260915_godfile_round5/061_server_index_outcome.md rename to devlog/_fin/260915_godfile_round5/061_server_index_outcome.md diff --git a/devlog/_plan/260915_godfile_round5/070_core_outcome.md b/devlog/_fin/260915_godfile_round5/070_core_outcome.md similarity index 100% rename from devlog/_plan/260915_godfile_round5/070_core_outcome.md rename to devlog/_fin/260915_godfile_round5/070_core_outcome.md diff --git a/devlog/_plan/260915_godfile_round5/080_round_outcome.md b/devlog/_fin/260915_godfile_round5/080_round_outcome.md similarity index 100% rename from devlog/_plan/260915_godfile_round5/080_round_outcome.md rename to devlog/_fin/260915_godfile_round5/080_round_outcome.md diff --git a/devlog/_plan/260915_workflow_budget_window/000_unit.md b/devlog/_fin/260915_workflow_budget_window/000_unit.md similarity index 100% rename from devlog/_plan/260915_workflow_budget_window/000_unit.md rename to devlog/_fin/260915_workflow_budget_window/000_unit.md diff --git a/devlog/_plan/260915_workflow_budget_window/010_windowed_ceilings.md b/devlog/_fin/260915_workflow_budget_window/010_windowed_ceilings.md similarity index 100% rename from devlog/_plan/260915_workflow_budget_window/010_windowed_ceilings.md rename to devlog/_fin/260915_workflow_budget_window/010_windowed_ceilings.md diff --git a/devlog/_plan/260915_workflow_budget_window/020_legible_refusal.md b/devlog/_fin/260915_workflow_budget_window/020_legible_refusal.md similarity index 100% rename from devlog/_plan/260915_workflow_budget_window/020_legible_refusal.md rename to devlog/_fin/260915_workflow_budget_window/020_legible_refusal.md diff --git a/devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md b/devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md similarity index 100% rename from devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md rename to devlog/_fin/260915_workflow_budget_window/030_wfc_diff_plan.md diff --git a/devlog/_plan/260916_cursor_http2_toolcall/000_plan.md b/devlog/_fin/260916_cursor_http2_toolcall/000_plan.md similarity index 100% rename from devlog/_plan/260916_cursor_http2_toolcall/000_plan.md rename to devlog/_fin/260916_cursor_http2_toolcall/000_plan.md diff --git a/devlog/_plan/260916_cursor_http2_toolcall/010_phase1_text_toolcall_quarantine.md b/devlog/_fin/260916_cursor_http2_toolcall/010_phase1_text_toolcall_quarantine.md similarity index 100% rename from devlog/_plan/260916_cursor_http2_toolcall/010_phase1_text_toolcall_quarantine.md rename to devlog/_fin/260916_cursor_http2_toolcall/010_phase1_text_toolcall_quarantine.md diff --git a/devlog/_plan/260916_cursor_http2_toolcall/020_phase2_observed_max_tokens.md b/devlog/_fin/260916_cursor_http2_toolcall/020_phase2_observed_max_tokens.md similarity index 100% rename from devlog/_plan/260916_cursor_http2_toolcall/020_phase2_observed_max_tokens.md rename to devlog/_fin/260916_cursor_http2_toolcall/020_phase2_observed_max_tokens.md diff --git a/devlog/_plan/260916_release_2570_stabilization/031_openai_tiers_split.md b/devlog/_fin/260916_release_2570_stabilization/031_openai_tiers_split.md similarity index 100% rename from devlog/_plan/260916_release_2570_stabilization/031_openai_tiers_split.md rename to devlog/_fin/260916_release_2570_stabilization/031_openai_tiers_split.md diff --git a/devlog/_plan/260916_release_2570_stabilization/041_preview_fence_test_is_structural.md b/devlog/_fin/260916_release_2570_stabilization/041_preview_fence_test_is_structural.md similarity index 100% rename from devlog/_plan/260916_release_2570_stabilization/041_preview_fence_test_is_structural.md rename to devlog/_fin/260916_release_2570_stabilization/041_preview_fence_test_is_structural.md diff --git a/devlog/_plan/260917_2570_release_train/000_roadmap.md b/devlog/_fin/260917_2570_release_train/000_roadmap.md similarity index 100% rename from devlog/_plan/260917_2570_release_train/000_roadmap.md rename to devlog/_fin/260917_2570_release_train/000_roadmap.md diff --git a/devlog/_plan/260917_2570_release_train/010_dev_green.md b/devlog/_fin/260917_2570_release_train/010_dev_green.md similarity index 100% rename from devlog/_plan/260917_2570_release_train/010_dev_green.md rename to devlog/_fin/260917_2570_release_train/010_dev_green.md diff --git a/devlog/_plan/260917_2570_release_train/020_pr_triage.md b/devlog/_fin/260917_2570_release_train/020_pr_triage.md similarity index 100% rename from devlog/_plan/260917_2570_release_train/020_pr_triage.md rename to devlog/_fin/260917_2570_release_train/020_pr_triage.md diff --git a/devlog/_plan/260917_2570_release_train/030_issue_triage.md b/devlog/_fin/260917_2570_release_train/030_issue_triage.md similarity index 100% rename from devlog/_plan/260917_2570_release_train/030_issue_triage.md rename to devlog/_fin/260917_2570_release_train/030_issue_triage.md diff --git a/devlog/_plan/260917_2570_release_train/040_release.md b/devlog/_fin/260917_2570_release_train/040_release.md similarity index 100% rename from devlog/_plan/260917_2570_release_train/040_release.md rename to devlog/_fin/260917_2570_release_train/040_release.md diff --git a/devlog/_plan/260917_devin_input_ceiling/000_review.md b/devlog/_fin/260917_devin_input_ceiling/000_review.md similarity index 100% rename from devlog/_plan/260917_devin_input_ceiling/000_review.md rename to devlog/_fin/260917_devin_input_ceiling/000_review.md diff --git a/devlog/_plan/260917_l1_preview_read_fence_and_dep_audit/000_master_plan.md b/devlog/_fin/260917_l1_preview_read_fence_and_dep_audit/000_master_plan.md similarity index 100% rename from devlog/_plan/260917_l1_preview_read_fence_and_dep_audit/000_master_plan.md rename to devlog/_fin/260917_l1_preview_read_fence_and_dep_audit/000_master_plan.md diff --git a/devlog/_plan/260917_l1_preview_read_fence_and_dep_audit/010_issue_4850_preview_pool_eligibility_fence.md b/devlog/_fin/260917_l1_preview_read_fence_and_dep_audit/010_issue_4850_preview_pool_eligibility_fence.md similarity index 100% rename from devlog/_plan/260917_l1_preview_read_fence_and_dep_audit/010_issue_4850_preview_pool_eligibility_fence.md rename to devlog/_fin/260917_l1_preview_read_fence_and_dep_audit/010_issue_4850_preview_pool_eligibility_fence.md diff --git a/devlog/_plan/260917_l1_preview_read_fence_and_dep_audit/020_pr_4873_dependency_audit_review.md b/devlog/_fin/260917_l1_preview_read_fence_and_dep_audit/020_pr_4873_dependency_audit_review.md similarity index 100% rename from devlog/_plan/260917_l1_preview_read_fence_and_dep_audit/020_pr_4873_dependency_audit_review.md rename to devlog/_fin/260917_l1_preview_read_fence_and_dep_audit/020_pr_4873_dependency_audit_review.md diff --git a/devlog/_plan/260917_l1_preview_read_fence_and_dep_audit/030_unit_c_docs_site_build_gate.md b/devlog/_fin/260917_l1_preview_read_fence_and_dep_audit/030_unit_c_docs_site_build_gate.md similarity index 100% rename from devlog/_plan/260917_l1_preview_read_fence_and_dep_audit/030_unit_c_docs_site_build_gate.md rename to devlog/_fin/260917_l1_preview_read_fence_and_dep_audit/030_unit_c_docs_site_build_gate.md diff --git a/devlog/_plan/260917_l2_safe_teardown/000_master_plan.md b/devlog/_fin/260917_l2_safe_teardown/000_master_plan.md similarity index 98% rename from devlog/_plan/260917_l2_safe_teardown/000_master_plan.md rename to devlog/_fin/260917_l2_safe_teardown/000_master_plan.md index 1c39d9be975..76738e7b633 100644 --- a/devlog/_plan/260917_l2_safe_teardown/000_master_plan.md +++ b/devlog/_fin/260917_l2_safe_teardown/000_master_plan.md @@ -123,7 +123,7 @@ Child (#4809): ## Prior art in this repository -`devlog/_plan/260914_codex_history_preflight_scope/` narrowed the same guard +`devlog/_fin/260914_codex_history_preflight_scope/` narrowed the same guard on the apply direction and explicitly left this open: > "The uninstall deadlock on an already-paginated home remains open follow-up; diff --git a/devlog/_plan/260917_l2_safe_teardown/010_upstream_resolution_facts.md b/devlog/_fin/260917_l2_safe_teardown/010_upstream_resolution_facts.md similarity index 100% rename from devlog/_plan/260917_l2_safe_teardown/010_upstream_resolution_facts.md rename to devlog/_fin/260917_l2_safe_teardown/010_upstream_resolution_facts.md diff --git a/devlog/_plan/260917_l2_safe_teardown/020_issue_4812_contract.md b/devlog/_fin/260917_l2_safe_teardown/020_issue_4812_contract.md similarity index 100% rename from devlog/_plan/260917_l2_safe_teardown/020_issue_4812_contract.md rename to devlog/_fin/260917_l2_safe_teardown/020_issue_4812_contract.md diff --git a/devlog/_plan/260917_l2_safe_teardown/030_issue_4809_contract.md b/devlog/_fin/260917_l2_safe_teardown/030_issue_4809_contract.md similarity index 100% rename from devlog/_plan/260917_l2_safe_teardown/030_issue_4809_contract.md rename to devlog/_fin/260917_l2_safe_teardown/030_issue_4809_contract.md diff --git a/devlog/_plan/260917_l2_safe_teardown/040_verification.md b/devlog/_fin/260917_l2_safe_teardown/040_verification.md similarity index 100% rename from devlog/_plan/260917_l2_safe_teardown/040_verification.md rename to devlog/_fin/260917_l2_safe_teardown/040_verification.md diff --git a/devlog/_plan/260917_l3_retry_budget_admission/000_roadmap.md b/devlog/_fin/260917_l3_retry_budget_admission/000_roadmap.md similarity index 100% rename from devlog/_plan/260917_l3_retry_budget_admission/000_roadmap.md rename to devlog/_fin/260917_l3_retry_budget_admission/000_roadmap.md diff --git a/devlog/_plan/260917_l3_retry_budget_admission/010_admission_audit.md b/devlog/_fin/260917_l3_retry_budget_admission/010_admission_audit.md similarity index 100% rename from devlog/_plan/260917_l3_retry_budget_admission/010_admission_audit.md rename to devlog/_fin/260917_l3_retry_budget_admission/010_admission_audit.md diff --git a/devlog/_plan/260917_l3_retry_budget_admission/020_independent_review.md b/devlog/_fin/260917_l3_retry_budget_admission/020_independent_review.md similarity index 100% rename from devlog/_plan/260917_l3_retry_budget_admission/020_independent_review.md rename to devlog/_fin/260917_l3_retry_budget_admission/020_independent_review.md diff --git a/devlog/_plan/260917_l4_responses_private_fields_and_history/010_roadmap.md b/devlog/_fin/260917_l4_responses_private_fields_and_history/010_roadmap.md similarity index 100% rename from devlog/_plan/260917_l4_responses_private_fields_and_history/010_roadmap.md rename to devlog/_fin/260917_l4_responses_private_fields_and_history/010_roadmap.md diff --git a/devlog/_plan/260917_l5_cursor_stabilization/000_roadmap.md b/devlog/_fin/260917_l5_cursor_stabilization/000_roadmap.md similarity index 100% rename from devlog/_plan/260917_l5_cursor_stabilization/000_roadmap.md rename to devlog/_fin/260917_l5_cursor_stabilization/000_roadmap.md diff --git a/devlog/_plan/260917_l5_cursor_stabilization/010_u1_text_toolcall_contract.md b/devlog/_fin/260917_l5_cursor_stabilization/010_u1_text_toolcall_contract.md similarity index 100% rename from devlog/_plan/260917_l5_cursor_stabilization/010_u1_text_toolcall_contract.md rename to devlog/_fin/260917_l5_cursor_stabilization/010_u1_text_toolcall_contract.md diff --git a/devlog/_plan/260917_l5_cursor_stabilization/020_u2_observed_window_scope.md b/devlog/_fin/260917_l5_cursor_stabilization/020_u2_observed_window_scope.md similarity index 100% rename from devlog/_plan/260917_l5_cursor_stabilization/020_u2_observed_window_scope.md rename to devlog/_fin/260917_l5_cursor_stabilization/020_u2_observed_window_scope.md diff --git a/devlog/_plan/260917_l5_cursor_stabilization/030_u3_remint_isolation_and_budget.md b/devlog/_fin/260917_l5_cursor_stabilization/030_u3_remint_isolation_and_budget.md similarity index 100% rename from devlog/_plan/260917_l5_cursor_stabilization/030_u3_remint_isolation_and_budget.md rename to devlog/_fin/260917_l5_cursor_stabilization/030_u3_remint_isolation_and_budget.md diff --git a/devlog/_plan/260917_l5_cursor_stabilization/040_u4_codebuddy_bare_tool_names.md b/devlog/_fin/260917_l5_cursor_stabilization/040_u4_codebuddy_bare_tool_names.md similarity index 100% rename from devlog/_plan/260917_l5_cursor_stabilization/040_u4_codebuddy_bare_tool_names.md rename to devlog/_fin/260917_l5_cursor_stabilization/040_u4_codebuddy_bare_tool_names.md diff --git a/devlog/_plan/260917_l6_contract_quality_fixes/000_master_plan.md b/devlog/_fin/260917_l6_contract_quality_fixes/000_master_plan.md similarity index 100% rename from devlog/_plan/260917_l6_contract_quality_fixes/000_master_plan.md rename to devlog/_fin/260917_l6_contract_quality_fixes/000_master_plan.md diff --git a/devlog/_plan/260917_l6_contract_quality_fixes/010_4855_terminal_stop_classification.md b/devlog/_fin/260917_l6_contract_quality_fixes/010_4855_terminal_stop_classification.md similarity index 100% rename from devlog/_plan/260917_l6_contract_quality_fixes/010_4855_terminal_stop_classification.md rename to devlog/_fin/260917_l6_contract_quality_fixes/010_4855_terminal_stop_classification.md diff --git a/devlog/_plan/260917_l6_contract_quality_fixes/020_4822_zai_model_discovery.md b/devlog/_fin/260917_l6_contract_quality_fixes/020_4822_zai_model_discovery.md similarity index 100% rename from devlog/_plan/260917_l6_contract_quality_fixes/020_4822_zai_model_discovery.md rename to devlog/_fin/260917_l6_contract_quality_fixes/020_4822_zai_model_discovery.md diff --git a/devlog/_plan/260917_l6_contract_quality_fixes/030_carried_catalog_and_gui_prs.md b/devlog/_fin/260917_l6_contract_quality_fixes/030_carried_catalog_and_gui_prs.md similarity index 100% rename from devlog/_plan/260917_l6_contract_quality_fixes/030_carried_catalog_and_gui_prs.md rename to devlog/_fin/260917_l6_contract_quality_fixes/030_carried_catalog_and_gui_prs.md diff --git a/devlog/_plan/260917_l6_contract_quality_fixes/040_4857_first_frame_usage_contract.md b/devlog/_fin/260917_l6_contract_quality_fixes/040_4857_first_frame_usage_contract.md similarity index 100% rename from devlog/_plan/260917_l6_contract_quality_fixes/040_4857_first_frame_usage_contract.md rename to devlog/_fin/260917_l6_contract_quality_fixes/040_4857_first_frame_usage_contract.md diff --git a/devlog/_plan/260917_l7_native_control_stack_audit/000_plan.md b/devlog/_fin/260917_l7_native_control_stack_audit/000_plan.md similarity index 96% rename from devlog/_plan/260917_l7_native_control_stack_audit/000_plan.md rename to devlog/_fin/260917_l7_native_control_stack_audit/000_plan.md index 74fb88be8a1..51b66a9c527 100644 --- a/devlog/_plan/260917_l7_native_control_stack_audit/000_plan.md +++ b/devlog/_fin/260917_l7_native_control_stack_audit/000_plan.md @@ -26,7 +26,7 @@ rebased or reimplemented here. ## Write scope -`devlog/_plan/260917_l7_native_control_stack_audit/` only. No `src/`, no +`devlog/_fin/260917_l7_native_control_stack_audit/` only. No `src/`, no `tests/`, no `structure/`, no `docs-site/`. The four audited branches are read through `git show` and `git diff` against fetched `refs/pull/*/head`; nothing in this branch touches them. diff --git a/devlog/_plan/260917_l7_native_control_stack_audit/010_stage_diffs.md b/devlog/_fin/260917_l7_native_control_stack_audit/010_stage_diffs.md similarity index 100% rename from devlog/_plan/260917_l7_native_control_stack_audit/010_stage_diffs.md rename to devlog/_fin/260917_l7_native_control_stack_audit/010_stage_diffs.md diff --git a/devlog/_plan/260917_l7_native_control_stack_audit/020_integration_contract.md b/devlog/_fin/260917_l7_native_control_stack_audit/020_integration_contract.md similarity index 100% rename from devlog/_plan/260917_l7_native_control_stack_audit/020_integration_contract.md rename to devlog/_fin/260917_l7_native_control_stack_audit/020_integration_contract.md diff --git a/devlog/_plan/260917_l7_native_control_stack_audit/030_l1_precondition.md b/devlog/_fin/260917_l7_native_control_stack_audit/030_l1_precondition.md similarity index 100% rename from devlog/_plan/260917_l7_native_control_stack_audit/030_l1_precondition.md rename to devlog/_fin/260917_l7_native_control_stack_audit/030_l1_precondition.md diff --git a/devlog/_plan/260917_l7_native_control_stack_audit/040_activation_decision.md b/devlog/_fin/260917_l7_native_control_stack_audit/040_activation_decision.md similarity index 100% rename from devlog/_plan/260917_l7_native_control_stack_audit/040_activation_decision.md rename to devlog/_fin/260917_l7_native_control_stack_audit/040_activation_decision.md diff --git a/devlog/_plan/260918_2580_release_train/000_roadmap.md b/devlog/_fin/260918_2580_release_train/000_roadmap.md similarity index 100% rename from devlog/_plan/260918_2580_release_train/000_roadmap.md rename to devlog/_fin/260918_2580_release_train/000_roadmap.md diff --git a/devlog/_plan/260918_2580_release_train/040_release.md b/devlog/_fin/260918_2580_release_train/040_release.md similarity index 100% rename from devlog/_plan/260918_2580_release_train/040_release.md rename to devlog/_fin/260918_2580_release_train/040_release.md diff --git a/devlog/_plan/260918_lane_a_bug_train/010_roadmap.md b/devlog/_fin/260918_lane_a_bug_train/010_roadmap.md similarity index 100% rename from devlog/_plan/260918_lane_a_bug_train/010_roadmap.md rename to devlog/_fin/260918_lane_a_bug_train/010_roadmap.md diff --git a/devlog/_plan/260918_lane_a_bug_train/030_transient_passthrough_policy.md b/devlog/_fin/260918_lane_a_bug_train/030_transient_passthrough_policy.md similarity index 100% rename from devlog/_plan/260918_lane_a_bug_train/030_transient_passthrough_policy.md rename to devlog/_fin/260918_lane_a_bug_train/030_transient_passthrough_policy.md diff --git a/devlog/_plan/260918_lane_a_bug_train/040_combo_response_format.md b/devlog/_fin/260918_lane_a_bug_train/040_combo_response_format.md similarity index 100% rename from devlog/_plan/260918_lane_a_bug_train/040_combo_response_format.md rename to devlog/_fin/260918_lane_a_bug_train/040_combo_response_format.md diff --git a/devlog/_plan/260918_lane_a_bug_train/041_combo_response_format_second_vendor.md b/devlog/_fin/260918_lane_a_bug_train/041_combo_response_format_second_vendor.md similarity index 100% rename from devlog/_plan/260918_lane_a_bug_train/041_combo_response_format_second_vendor.md rename to devlog/_fin/260918_lane_a_bug_train/041_combo_response_format_second_vendor.md diff --git a/devlog/_plan/260918_lane_a_bug_train/050_cohort_pool_affinity.md b/devlog/_fin/260918_lane_a_bug_train/050_cohort_pool_affinity.md similarity index 100% rename from devlog/_plan/260918_lane_a_bug_train/050_cohort_pool_affinity.md rename to devlog/_fin/260918_lane_a_bug_train/050_cohort_pool_affinity.md diff --git a/devlog/_plan/260918_lane_b_provider_reliability/000_roadmap.md b/devlog/_fin/260918_lane_b_provider_reliability/000_roadmap.md similarity index 100% rename from devlog/_plan/260918_lane_b_provider_reliability/000_roadmap.md rename to devlog/_fin/260918_lane_b_provider_reliability/000_roadmap.md diff --git a/devlog/_plan/260918_ld_search_bridge_and_thinking_replay/010_roadmap.md b/devlog/_fin/260918_ld_search_bridge_and_thinking_replay/010_roadmap.md similarity index 100% rename from devlog/_plan/260918_ld_search_bridge_and_thinking_replay/010_roadmap.md rename to devlog/_fin/260918_ld_search_bridge_and_thinking_replay/010_roadmap.md diff --git a/devlog/_plan/260918_ld_search_bridge_and_thinking_replay/020_closure_evidence.md b/devlog/_fin/260918_ld_search_bridge_and_thinking_replay/020_closure_evidence.md similarity index 100% rename from devlog/_plan/260918_ld_search_bridge_and_thinking_replay/020_closure_evidence.md rename to devlog/_fin/260918_ld_search_bridge_and_thinking_replay/020_closure_evidence.md diff --git a/devlog/_plan/260919_5008_antigravity_turn_ordering/000_upstream_contract_evidence.md b/devlog/_fin/260919_5008_antigravity_turn_ordering/000_upstream_contract_evidence.md similarity index 100% rename from devlog/_plan/260919_5008_antigravity_turn_ordering/000_upstream_contract_evidence.md rename to devlog/_fin/260919_5008_antigravity_turn_ordering/000_upstream_contract_evidence.md diff --git a/devlog/_fin/260919_contract_resolution/001_status.md b/devlog/_fin/260919_contract_resolution/001_status.md index 454a1b4be0f..5c5b339860b 100644 --- a/devlog/_fin/260919_contract_resolution/001_status.md +++ b/devlog/_fin/260919_contract_resolution/001_status.md @@ -1,6 +1,6 @@ # Campaign status -Status: IMPLEMENTATION COMPLETE. The active regression and release follow-up is tracked in [its separate open unit](../../_plan/260920_regression_release/000_scope.md). All sixteen original implementation issues are now closed. Final dashboard #5197 landed as `ed44e04a933f6d4d62d2e049bf7606f08d347b06`; #5118 was closed after ancestry/tree checks and current source/fixture evidence. The owner explicitly requested immediate integration while macOS verification remained unfinished, and that timing exception is recorded on the PR. No full-platform pass is claimed for that incomplete run. Complete hosted regression, main/preview promotion, release publication and post-publication verification remain outstanding. Two non-blocking review observations are retained for follow-up. The heartbeat remains active. +Status: IMPLEMENTATION COMPLETE. The active regression and release follow-up is tracked in [its separate open unit](../../_fin/260920_regression_release/000_scope.md). All sixteen original implementation issues are now closed. Final dashboard #5197 landed as `ed44e04a933f6d4d62d2e049bf7606f08d347b06`; #5118 was closed after ancestry/tree checks and current source/fixture evidence. The owner explicitly requested immediate integration while macOS verification remained unfinished, and that timing exception is recorded on the PR. No full-platform pass is claimed for that incomplete run. Complete hosted regression, main/preview promotion, release publication and post-publication verification remain outstanding. Two non-blocking review observations are retained for follow-up. The heartbeat remains active. ## Owners diff --git a/devlog/_fin/260919_contract_resolution/042_release_followup_authorization.md b/devlog/_fin/260919_contract_resolution/042_release_followup_authorization.md index b7f1ea71313..d62ca2065e6 100644 --- a/devlog/_fin/260919_contract_resolution/042_release_followup_authorization.md +++ b/devlog/_fin/260919_contract_resolution/042_release_followup_authorization.md @@ -14,6 +14,6 @@ Request-time branch references: The immutable `main` baseline preserves the requested regression scope even if another authorized integration later advances a branch. The final target is captured after the campaign lands; no missing change may be dropped merely by choosing a newer comparison base. -At authorization time, the current work phase still needed to finish #5118 and its acceptance evidence. That implementation prerequisite is now complete; the still-open release work is tracked in [the separate plan unit](../../_plan/260920_regression_release/000_scope.md). After that cycle closes, the next Plan phase must inspect the current release authority, versions, branch ancestry and workflow inputs; write the complete regression/promotion/release plan; and register its remaining criteria before execution. Do not infer a release version or dist-tag before that inspection. Use the repository's required version ordering and promotion sequence. +At authorization time, the current work phase still needed to finish #5118 and its acceptance evidence. That implementation prerequisite is now complete; the still-open release work is tracked in [the separate plan unit](../../_fin/260920_regression_release/000_scope.md). After that cycle closes, the next Plan phase must inspect the current release authority, versions, branch ancestry and workflow inputs; write the complete regression/promotion/release plan; and register its remaining criteria before execution. Do not infer a release version or dist-tag before that inspection. Use the repository's required version ordering and promotion sequence. Completion of the expanded request requires all original issue evidence plus the full regression result, verified `main`/`preview` promotions, release publication and post-publication evidence for the actual released version and commit. Keep those claims separate. The host goal must not complete at sixteen issue closures alone. Retain the heartbeat for the handoff and stop it only once all authorized follow-up work is verified. diff --git a/devlog/_fin/260919_contract_resolution/043_implementation_handoff.md b/devlog/_fin/260919_contract_resolution/043_implementation_handoff.md index bc41cf59f51..2b938d2072a 100644 --- a/devlog/_fin/260919_contract_resolution/043_implementation_handoff.md +++ b/devlog/_fin/260919_contract_resolution/043_implementation_handoff.md @@ -19,4 +19,4 @@ Use the request-time main baseline134c92a01b120162f00c7275189cc47858720379 throu Later verification: run35483995896 attempt1 completed SUCCESS at source278513a79e, including all event-applicable jobs and both macOS shards. The earlier integration timing exception remains a historical fact; the formerly pending source-PR platform checks are now complete. Full main-through-candidate regression, promotion and release remain separate follow-up work. -The still-open follow-up lives in [the regression and release unit](../../_plan/260920_regression_release/000_scope.md); this archive contains only the completed implementation record and its handoff. +The still-open follow-up lives in [the regression and release unit](../../_fin/260920_regression_release/000_scope.md); this archive contains only the completed implementation record and its handoff. diff --git a/devlog/_plan/260920_desktop_app_stabilization/000_local_build.md b/devlog/_fin/260920_desktop_app_stabilization/000_local_build.md similarity index 100% rename from devlog/_plan/260920_desktop_app_stabilization/000_local_build.md rename to devlog/_fin/260920_desktop_app_stabilization/000_local_build.md diff --git a/devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md b/devlog/_fin/260920_desktop_app_stabilization/010_roadmap.md similarity index 100% rename from devlog/_plan/260920_desktop_app_stabilization/010_roadmap.md rename to devlog/_fin/260920_desktop_app_stabilization/010_roadmap.md diff --git a/devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md b/devlog/_fin/260920_desktop_app_stabilization/011_acceptance.md similarity index 100% rename from devlog/_plan/260920_desktop_app_stabilization/011_acceptance.md rename to devlog/_fin/260920_desktop_app_stabilization/011_acceptance.md diff --git a/devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md b/devlog/_fin/260920_desktop_app_stabilization/020_build_state_guards.md similarity index 100% rename from devlog/_plan/260920_desktop_app_stabilization/020_build_state_guards.md rename to devlog/_fin/260920_desktop_app_stabilization/020_build_state_guards.md diff --git a/devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md b/devlog/_fin/260920_desktop_app_stabilization/030_icons_and_widget.md similarity index 100% rename from devlog/_plan/260920_desktop_app_stabilization/030_icons_and_widget.md rename to devlog/_fin/260920_desktop_app_stabilization/030_icons_and_widget.md diff --git a/devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md b/devlog/_fin/260920_desktop_app_stabilization/040_widget_never_offered.md similarity index 100% rename from devlog/_plan/260920_desktop_app_stabilization/040_widget_never_offered.md rename to devlog/_fin/260920_desktop_app_stabilization/040_widget_never_offered.md diff --git a/devlog/_plan/260920_desktop_app_stabilization/050_landing.md b/devlog/_fin/260920_desktop_app_stabilization/050_landing.md similarity index 100% rename from devlog/_plan/260920_desktop_app_stabilization/050_landing.md rename to devlog/_fin/260920_desktop_app_stabilization/050_landing.md diff --git a/devlog/_plan/260920_regression_release/000_scope.md b/devlog/_fin/260920_regression_release/000_scope.md similarity index 100% rename from devlog/_plan/260920_regression_release/000_scope.md rename to devlog/_fin/260920_regression_release/000_scope.md diff --git a/devlog/_plan/260920_regression_release/010_execution.md b/devlog/_fin/260920_regression_release/010_execution.md similarity index 100% rename from devlog/_plan/260920_regression_release/010_execution.md rename to devlog/_fin/260920_regression_release/010_execution.md diff --git a/devlog/_plan/260920_round2_followups/000_plan.md b/devlog/_fin/260920_round2_followups/000_plan.md similarity index 100% rename from devlog/_plan/260920_round2_followups/000_plan.md rename to devlog/_fin/260920_round2_followups/000_plan.md diff --git a/devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md b/devlog/_fin/260920_round2_followups/010_r1_paginated_history_guard.md similarity index 100% rename from devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md rename to devlog/_fin/260920_round2_followups/010_r1_paginated_history_guard.md diff --git a/devlog/_plan/260920_round2_followups/020_r2_desktop_ci.md b/devlog/_fin/260920_round2_followups/020_r2_desktop_ci.md similarity index 100% rename from devlog/_plan/260920_round2_followups/020_r2_desktop_ci.md rename to devlog/_fin/260920_round2_followups/020_r2_desktop_ci.md diff --git a/devlog/_plan/260920_round2_followups/030_lane_r3.md b/devlog/_fin/260920_round2_followups/030_lane_r3.md similarity index 100% rename from devlog/_plan/260920_round2_followups/030_lane_r3.md rename to devlog/_fin/260920_round2_followups/030_lane_r3.md diff --git a/devlog/_plan/260920_round2_followups/040_r4_retry_rework.md b/devlog/_fin/260920_round2_followups/040_r4_retry_rework.md similarity index 100% rename from devlog/_plan/260920_round2_followups/040_r4_retry_rework.md rename to devlog/_fin/260920_round2_followups/040_r4_retry_rework.md diff --git a/devlog/_plan/260920_round2_followups/050_lane_r5.md b/devlog/_fin/260920_round2_followups/050_lane_r5.md similarity index 99% rename from devlog/_plan/260920_round2_followups/050_lane_r5.md rename to devlog/_fin/260920_round2_followups/050_lane_r5.md index 7343408d0cf..e666cf2f3e9 100644 --- a/devlog/_plan/260920_round2_followups/050_lane_r5.md +++ b/devlog/_fin/260920_round2_followups/050_lane_r5.md @@ -4,7 +4,7 @@ Status: OPEN. Branch `codex/260920-r5-telemetry-derived`, rebased onto `dev` aft advanced mid-lane. One branch, ordered commits, one pull request to `dev`. Lane C deferred #2366, #3748, #3983 and #5063 "as implemented", because each adds a parallel store -or a second emission path. [030_lane_c2.md](../260920_meaning_preservation_batch/030_lane_c2.md) +or a second emission path. [030_lane_c2.md](../../_plan/260920_meaning_preservation_batch/030_lane_c2.md) then specified the derived form for each. This lane builds those four forms. It adds no store: the durable shapes stay `PersistedUsageAttempt` and `PersistedUsageEntry`, and every projection reads them. diff --git a/devlog/_plan/260920_round2_followups/050_r4_remainders.md b/devlog/_fin/260920_round2_followups/050_r4_remainders.md similarity index 100% rename from devlog/_plan/260920_round2_followups/050_r4_remainders.md rename to devlog/_fin/260920_round2_followups/050_r4_remainders.md diff --git a/devlog/_plan/260920_round2_followups/060_r6_usage_models_table.md b/devlog/_fin/260920_round2_followups/060_r6_usage_models_table.md similarity index 100% rename from devlog/_plan/260920_round2_followups/060_r6_usage_models_table.md rename to devlog/_fin/260920_round2_followups/060_r6_usage_models_table.md diff --git a/devlog/_plan/260920_round2_followups/070_widget_signing.md b/devlog/_fin/260920_round2_followups/070_widget_signing.md similarity index 100% rename from devlog/_plan/260920_round2_followups/070_widget_signing.md rename to devlog/_fin/260920_round2_followups/070_widget_signing.md diff --git a/devlog/_plan/260920_round2_followups/090_closeout.md b/devlog/_fin/260920_round2_followups/090_closeout.md similarity index 100% rename from devlog/_plan/260920_round2_followups/090_closeout.md rename to devlog/_fin/260920_round2_followups/090_closeout.md diff --git a/devlog/_plan/260920_round2_followups/r6-usage-table/r6-01-column-order.png b/devlog/_fin/260920_round2_followups/r6-usage-table/r6-01-column-order.png similarity index 100% rename from devlog/_plan/260920_round2_followups/r6-usage-table/r6-01-column-order.png rename to devlog/_fin/260920_round2_followups/r6-usage-table/r6-01-column-order.png diff --git a/devlog/_plan/260920_round2_followups/r6-usage-table/r6-02-pinned-scroll.png b/devlog/_fin/260920_round2_followups/r6-usage-table/r6-02-pinned-scroll.png similarity index 100% rename from devlog/_plan/260920_round2_followups/r6-usage-table/r6-02-pinned-scroll.png rename to devlog/_fin/260920_round2_followups/r6-usage-table/r6-02-pinned-scroll.png diff --git a/devlog/_plan/260920_round2_followups/r6-usage-table/r6-03-narrow-fallback.png b/devlog/_fin/260920_round2_followups/r6-usage-table/r6-03-narrow-fallback.png similarity index 100% rename from devlog/_plan/260920_round2_followups/r6-usage-table/r6-03-narrow-fallback.png rename to devlog/_fin/260920_round2_followups/r6-usage-table/r6-03-narrow-fallback.png diff --git a/devlog/_plan/260921_app_runtime_ownership/000_charter.md b/devlog/_fin/260921_app_runtime_ownership/000_charter.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/000_charter.md rename to devlog/_fin/260921_app_runtime_ownership/000_charter.md diff --git a/devlog/_plan/260921_app_runtime_ownership/010_coexistence_findings.md b/devlog/_fin/260921_app_runtime_ownership/010_coexistence_findings.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/010_coexistence_findings.md rename to devlog/_fin/260921_app_runtime_ownership/010_coexistence_findings.md diff --git a/devlog/_plan/260921_app_runtime_ownership/020_windows_linux_findings.md b/devlog/_fin/260921_app_runtime_ownership/020_windows_linux_findings.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/020_windows_linux_findings.md rename to devlog/_fin/260921_app_runtime_ownership/020_windows_linux_findings.md diff --git a/devlog/_plan/260921_app_runtime_ownership/030_contradictions.md b/devlog/_fin/260921_app_runtime_ownership/030_contradictions.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/030_contradictions.md rename to devlog/_fin/260921_app_runtime_ownership/030_contradictions.md diff --git a/devlog/_plan/260921_app_runtime_ownership/040_verification_hosts.md b/devlog/_fin/260921_app_runtime_ownership/040_verification_hosts.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/040_verification_hosts.md rename to devlog/_fin/260921_app_runtime_ownership/040_verification_hosts.md diff --git a/devlog/_plan/260921_app_runtime_ownership/050_webview_dialogs.md b/devlog/_fin/260921_app_runtime_ownership/050_webview_dialogs.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/050_webview_dialogs.md rename to devlog/_fin/260921_app_runtime_ownership/050_webview_dialogs.md diff --git a/devlog/_plan/260921_app_runtime_ownership/060_contradictions_round2.md b/devlog/_fin/260921_app_runtime_ownership/060_contradictions_round2.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/060_contradictions_round2.md rename to devlog/_fin/260921_app_runtime_ownership/060_contradictions_round2.md diff --git a/devlog/_plan/260921_app_runtime_ownership/070_decisions.md b/devlog/_fin/260921_app_runtime_ownership/070_decisions.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/070_decisions.md rename to devlog/_fin/260921_app_runtime_ownership/070_decisions.md diff --git a/devlog/_plan/260921_app_runtime_ownership/080_decisions_round2.md b/devlog/_fin/260921_app_runtime_ownership/080_decisions_round2.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/080_decisions_round2.md rename to devlog/_fin/260921_app_runtime_ownership/080_decisions_round2.md diff --git a/devlog/_plan/260921_app_runtime_ownership/090_lanes.md b/devlog/_fin/260921_app_runtime_ownership/090_lanes.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/090_lanes.md rename to devlog/_fin/260921_app_runtime_ownership/090_lanes.md diff --git a/devlog/_plan/260921_app_runtime_ownership/100_resolutions.md b/devlog/_fin/260921_app_runtime_ownership/100_resolutions.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/100_resolutions.md rename to devlog/_fin/260921_app_runtime_ownership/100_resolutions.md diff --git a/devlog/_plan/260921_app_runtime_ownership/110_reaudit.md b/devlog/_fin/260921_app_runtime_ownership/110_reaudit.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/110_reaudit.md rename to devlog/_fin/260921_app_runtime_ownership/110_reaudit.md diff --git a/devlog/_plan/260921_app_runtime_ownership/120_gate_runbook.md b/devlog/_fin/260921_app_runtime_ownership/120_gate_runbook.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/120_gate_runbook.md rename to devlog/_fin/260921_app_runtime_ownership/120_gate_runbook.md diff --git a/devlog/_plan/260921_app_runtime_ownership/120_install_verification.md b/devlog/_fin/260921_app_runtime_ownership/120_install_verification.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/120_install_verification.md rename to devlog/_fin/260921_app_runtime_ownership/120_install_verification.md diff --git a/devlog/_plan/260921_app_runtime_ownership/130_linux_surface_findings.md b/devlog/_fin/260921_app_runtime_ownership/130_linux_surface_findings.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/130_linux_surface_findings.md rename to devlog/_fin/260921_app_runtime_ownership/130_linux_surface_findings.md diff --git a/devlog/_plan/260921_app_runtime_ownership/140_tray_popup_polish.md b/devlog/_fin/260921_app_runtime_ownership/140_tray_popup_polish.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/140_tray_popup_polish.md rename to devlog/_fin/260921_app_runtime_ownership/140_tray_popup_polish.md diff --git a/devlog/_plan/260921_app_runtime_ownership/150_closeout.md b/devlog/_fin/260921_app_runtime_ownership/150_closeout.md similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/150_closeout.md rename to devlog/_fin/260921_app_runtime_ownership/150_closeout.md diff --git a/devlog/_plan/260921_app_runtime_ownership/tray-usage-popup-glass.png b/devlog/_fin/260921_app_runtime_ownership/tray-usage-popup-glass.png similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/tray-usage-popup-glass.png rename to devlog/_fin/260921_app_runtime_ownership/tray-usage-popup-glass.png diff --git a/devlog/_plan/260921_app_runtime_ownership/tray-usage-popup.png b/devlog/_fin/260921_app_runtime_ownership/tray-usage-popup.png similarity index 100% rename from devlog/_plan/260921_app_runtime_ownership/tray-usage-popup.png rename to devlog/_fin/260921_app_runtime_ownership/tray-usage-popup.png diff --git a/devlog/_plan/260922_native_tray_release/000_plan.md b/devlog/_fin/260922_native_tray_release/000_plan.md similarity index 100% rename from devlog/_plan/260922_native_tray_release/000_plan.md rename to devlog/_fin/260922_native_tray_release/000_plan.md diff --git a/devlog/_plan/260922_native_tray_release/001_evidence.md b/devlog/_fin/260922_native_tray_release/001_evidence.md similarity index 100% rename from devlog/_plan/260922_native_tray_release/001_evidence.md rename to devlog/_fin/260922_native_tray_release/001_evidence.md diff --git a/devlog/_plan/260922_native_tray_release/002_plan_review.md b/devlog/_fin/260922_native_tray_release/002_plan_review.md similarity index 100% rename from devlog/_plan/260922_native_tray_release/002_plan_review.md rename to devlog/_fin/260922_native_tray_release/002_plan_review.md diff --git a/devlog/_plan/260922_native_tray_release/010_native_presentation.md b/devlog/_fin/260922_native_tray_release/010_native_presentation.md similarity index 100% rename from devlog/_plan/260922_native_tray_release/010_native_presentation.md rename to devlog/_fin/260922_native_tray_release/010_native_presentation.md diff --git a/devlog/_plan/260922_native_tray_release/011_presentation_verification.md b/devlog/_fin/260922_native_tray_release/011_presentation_verification.md similarity index 100% rename from devlog/_plan/260922_native_tray_release/011_presentation_verification.md rename to devlog/_fin/260922_native_tray_release/011_presentation_verification.md diff --git a/devlog/_plan/260922_native_tray_release/020_shell_integration.md b/devlog/_fin/260922_native_tray_release/020_shell_integration.md similarity index 100% rename from devlog/_plan/260922_native_tray_release/020_shell_integration.md rename to devlog/_fin/260922_native_tray_release/020_shell_integration.md diff --git a/devlog/_plan/260922_native_tray_release/021_integration_verification.md b/devlog/_fin/260922_native_tray_release/021_integration_verification.md similarity index 100% rename from devlog/_plan/260922_native_tray_release/021_integration_verification.md rename to devlog/_fin/260922_native_tray_release/021_integration_verification.md diff --git a/devlog/_plan/260922_native_tray_release/030_regression.md b/devlog/_fin/260922_native_tray_release/030_regression.md similarity index 100% rename from devlog/_plan/260922_native_tray_release/030_regression.md rename to devlog/_fin/260922_native_tray_release/030_regression.md diff --git a/devlog/_plan/260922_native_tray_release/031_verification.md b/devlog/_fin/260922_native_tray_release/031_verification.md similarity index 100% rename from devlog/_plan/260922_native_tray_release/031_verification.md rename to devlog/_fin/260922_native_tray_release/031_verification.md diff --git a/devlog/_plan/260922_native_tray_release/040_release.md b/devlog/_fin/260922_native_tray_release/040_release.md similarity index 100% rename from devlog/_plan/260922_native_tray_release/040_release.md rename to devlog/_fin/260922_native_tray_release/040_release.md diff --git a/devlog/_plan/260922_native_tray_release/evidence/native-glass-bottom.png b/devlog/_fin/260922_native_tray_release/evidence/native-glass-bottom.png similarity index 100% rename from devlog/_plan/260922_native_tray_release/evidence/native-glass-bottom.png rename to devlog/_fin/260922_native_tray_release/evidence/native-glass-bottom.png diff --git a/devlog/_plan/260922_native_tray_release/evidence/native-glass-top.png b/devlog/_fin/260922_native_tray_release/evidence/native-glass-top.png similarity index 100% rename from devlog/_plan/260922_native_tray_release/evidence/native-glass-top.png rename to devlog/_fin/260922_native_tray_release/evidence/native-glass-top.png diff --git a/devlog/_plan/260922_native_tray_release/evidence/native-panel-bottom.png b/devlog/_fin/260922_native_tray_release/evidence/native-panel-bottom.png similarity index 100% rename from devlog/_plan/260922_native_tray_release/evidence/native-panel-bottom.png rename to devlog/_fin/260922_native_tray_release/evidence/native-panel-bottom.png diff --git a/devlog/_plan/260922_native_tray_release/evidence/native-panel-dark.png b/devlog/_fin/260922_native_tray_release/evidence/native-panel-dark.png similarity index 100% rename from devlog/_plan/260922_native_tray_release/evidence/native-panel-dark.png rename to devlog/_fin/260922_native_tray_release/evidence/native-panel-dark.png diff --git a/devlog/_plan/260922_native_tray_release/evidence/native-panel-light.png b/devlog/_fin/260922_native_tray_release/evidence/native-panel-light.png similarity index 100% rename from devlog/_plan/260922_native_tray_release/evidence/native-panel-light.png rename to devlog/_fin/260922_native_tray_release/evidence/native-panel-light.png diff --git a/devlog/_plan/260922_native_tray_release/evidence/web-tray-opaque-bottom.png b/devlog/_fin/260922_native_tray_release/evidence/web-tray-opaque-bottom.png similarity index 100% rename from devlog/_plan/260922_native_tray_release/evidence/web-tray-opaque-bottom.png rename to devlog/_fin/260922_native_tray_release/evidence/web-tray-opaque-bottom.png diff --git a/devlog/_plan/260922_native_tray_release/evidence/web-tray-vibrant-bottom.png b/devlog/_fin/260922_native_tray_release/evidence/web-tray-vibrant-bottom.png similarity index 100% rename from devlog/_plan/260922_native_tray_release/evidence/web-tray-vibrant-bottom.png rename to devlog/_fin/260922_native_tray_release/evidence/web-tray-vibrant-bottom.png diff --git a/devlog/_plan/260923_anthropic_fast_speed/010_plan.md b/devlog/_fin/260923_anthropic_fast_speed/010_plan.md similarity index 100% rename from devlog/_plan/260923_anthropic_fast_speed/010_plan.md rename to devlog/_fin/260923_anthropic_fast_speed/010_plan.md diff --git a/devlog/_plan/260923_anthropic_fast_speed/020_probe-evidence.md b/devlog/_fin/260923_anthropic_fast_speed/020_probe-evidence.md similarity index 100% rename from devlog/_plan/260923_anthropic_fast_speed/020_probe-evidence.md rename to devlog/_fin/260923_anthropic_fast_speed/020_probe-evidence.md diff --git a/devlog/_plan/260923_anthropic_fast_speed/030_reflection.md b/devlog/_fin/260923_anthropic_fast_speed/030_reflection.md similarity index 96% rename from devlog/_plan/260923_anthropic_fast_speed/030_reflection.md rename to devlog/_fin/260923_anthropic_fast_speed/030_reflection.md index a7af3f10043..01f06fd4232 100644 --- a/devlog/_plan/260923_anthropic_fast_speed/030_reflection.md +++ b/devlog/_fin/260923_anthropic_fast_speed/030_reflection.md @@ -1,6 +1,6 @@ # Reflection: Anthropic Fast D5 dispatch fallback -**Verdict: FAIL as written; NEAR-PASS after the concrete changes below.** D1-D4/D6 are consistent with the new probe: all three models accepted the OAuth Fast field but this account lacked entitlement (`devlog/_plan/260923_anthropic_fast_speed/020_probe-evidence.md:9-20,31-42`). The plan's pricing rule for both provider IDs is reasonable as a *confirmed-speed list-price estimate*, but D5's claim that `oauthDispatch` wraps every non-forward Anthropic send, and its proposed hidden second send there, do not hold (`010_plan.md:16-23`). This is a source audit, not a runtime test. +**Verdict: FAIL as written; NEAR-PASS after the concrete changes below.** D1-D4/D6 are consistent with the new probe: all three models accepted the OAuth Fast field but this account lacked entitlement (`devlog/_fin/260923_anthropic_fast_speed/020_probe-evidence.md:9-20,31-42`). The plan's pricing rule for both provider IDs is reasonable as a *confirmed-speed list-price estimate*, but D5's claim that `oauthDispatch` wraps every non-forward Anthropic send, and its proposed hidden second send there, do not hold (`010_plan.md:16-23`). This is a source audit, not a runtime test. ## Where `oauthDispatch` runs diff --git a/devlog/_plan/260923_anthropic_fast_speed/040_audit.md b/devlog/_fin/260923_anthropic_fast_speed/040_audit.md similarity index 100% rename from devlog/_plan/260923_anthropic_fast_speed/040_audit.md rename to devlog/_fin/260923_anthropic_fast_speed/040_audit.md diff --git a/devlog/_plan/260923_anthropic_fast_speed/050_build.md b/devlog/_fin/260923_anthropic_fast_speed/050_build.md similarity index 100% rename from devlog/_plan/260923_anthropic_fast_speed/050_build.md rename to devlog/_fin/260923_anthropic_fast_speed/050_build.md diff --git a/devlog/_plan/260923_anthropic_fast_speed/060_done.md b/devlog/_fin/260923_anthropic_fast_speed/060_done.md similarity index 100% rename from devlog/_plan/260923_anthropic_fast_speed/060_done.md rename to devlog/_fin/260923_anthropic_fast_speed/060_done.md diff --git a/devlog/_plan/260923_anthropic_fast_speed/evidence/logs-fast-downgrade.png b/devlog/_fin/260923_anthropic_fast_speed/evidence/logs-fast-downgrade.png similarity index 100% rename from devlog/_plan/260923_anthropic_fast_speed/evidence/logs-fast-downgrade.png rename to devlog/_fin/260923_anthropic_fast_speed/evidence/logs-fast-downgrade.png diff --git a/devlog/_plan/260923_bundle_a_tests_hygiene/000_triage.md b/devlog/_fin/260923_bundle_a_tests_hygiene/000_triage.md similarity index 100% rename from devlog/_plan/260923_bundle_a_tests_hygiene/000_triage.md rename to devlog/_fin/260923_bundle_a_tests_hygiene/000_triage.md diff --git a/devlog/_plan/260923_bundle_a_tests_hygiene/010_build.md b/devlog/_fin/260923_bundle_a_tests_hygiene/010_build.md similarity index 100% rename from devlog/_plan/260923_bundle_a_tests_hygiene/010_build.md rename to devlog/_fin/260923_bundle_a_tests_hygiene/010_build.md diff --git a/devlog/_plan/260923_bundle_a_tests_hygiene/020_delivery.md b/devlog/_fin/260923_bundle_a_tests_hygiene/020_delivery.md similarity index 100% rename from devlog/_plan/260923_bundle_a_tests_hygiene/020_delivery.md rename to devlog/_fin/260923_bundle_a_tests_hygiene/020_delivery.md diff --git a/devlog/_plan/260923_bundle_f1_provider_registry/000_overview.md b/devlog/_fin/260923_bundle_f1_provider_registry/000_overview.md similarity index 100% rename from devlog/_plan/260923_bundle_f1_provider_registry/000_overview.md rename to devlog/_fin/260923_bundle_f1_provider_registry/000_overview.md diff --git a/devlog/_plan/260923_bundle_f1_provider_registry/010_carry_plan.md b/devlog/_fin/260923_bundle_f1_provider_registry/010_carry_plan.md similarity index 100% rename from devlog/_plan/260923_bundle_f1_provider_registry/010_carry_plan.md rename to devlog/_fin/260923_bundle_f1_provider_registry/010_carry_plan.md diff --git a/devlog/_plan/260923_bundle_f1_provider_registry/020_delivery.md b/devlog/_fin/260923_bundle_f1_provider_registry/020_delivery.md similarity index 100% rename from devlog/_plan/260923_bundle_f1_provider_registry/020_delivery.md rename to devlog/_fin/260923_bundle_f1_provider_registry/020_delivery.md diff --git a/devlog/_plan/260923_bundle_lane_e/000_overview.md b/devlog/_fin/260923_bundle_lane_e/000_overview.md similarity index 100% rename from devlog/_plan/260923_bundle_lane_e/000_overview.md rename to devlog/_fin/260923_bundle_lane_e/000_overview.md diff --git a/devlog/_plan/260923_bundle_lane_e/010_decisions.md b/devlog/_fin/260923_bundle_lane_e/010_decisions.md similarity index 100% rename from devlog/_plan/260923_bundle_lane_e/010_decisions.md rename to devlog/_fin/260923_bundle_lane_e/010_decisions.md diff --git a/devlog/_plan/260923_bundle_lane_e/020_carry.md b/devlog/_fin/260923_bundle_lane_e/020_carry.md similarity index 100% rename from devlog/_plan/260923_bundle_lane_e/020_carry.md rename to devlog/_fin/260923_bundle_lane_e/020_carry.md diff --git a/devlog/_plan/260923_bundle_lane_e/030_verify.md b/devlog/_fin/260923_bundle_lane_e/030_verify.md similarity index 100% rename from devlog/_plan/260923_bundle_lane_e/030_verify.md rename to devlog/_fin/260923_bundle_lane_e/030_verify.md diff --git a/devlog/_plan/260923_claude_desktop_first_party_models/000_plan.md b/devlog/_fin/260923_claude_desktop_first_party_models/000_plan.md similarity index 100% rename from devlog/_plan/260923_claude_desktop_first_party_models/000_plan.md rename to devlog/_fin/260923_claude_desktop_first_party_models/000_plan.md diff --git a/devlog/_plan/260923_claude_desktop_first_party_models/001_probe_evidence.md b/devlog/_fin/260923_claude_desktop_first_party_models/001_probe_evidence.md similarity index 100% rename from devlog/_plan/260923_claude_desktop_first_party_models/001_probe_evidence.md rename to devlog/_fin/260923_claude_desktop_first_party_models/001_probe_evidence.md diff --git a/devlog/_plan/260923_claude_desktop_first_party_models/010_roadmap.md b/devlog/_fin/260923_claude_desktop_first_party_models/010_roadmap.md similarity index 100% rename from devlog/_plan/260923_claude_desktop_first_party_models/010_roadmap.md rename to devlog/_fin/260923_claude_desktop_first_party_models/010_roadmap.md diff --git a/devlog/_plan/260923_claude_desktop_first_party_models/020_wp2_first_party_bindings.md b/devlog/_fin/260923_claude_desktop_first_party_models/020_wp2_first_party_bindings.md similarity index 100% rename from devlog/_plan/260923_claude_desktop_first_party_models/020_wp2_first_party_bindings.md rename to devlog/_fin/260923_claude_desktop_first_party_models/020_wp2_first_party_bindings.md diff --git a/devlog/_plan/260923_claude_desktop_first_party_models/030_wp3_live_proof_and_pr.md b/devlog/_fin/260923_claude_desktop_first_party_models/030_wp3_live_proof_and_pr.md similarity index 100% rename from devlog/_plan/260923_claude_desktop_first_party_models/030_wp3_live_proof_and_pr.md rename to devlog/_fin/260923_claude_desktop_first_party_models/030_wp3_live_proof_and_pr.md diff --git a/devlog/_plan/260923_claude_reset_grants/010_plan.md b/devlog/_fin/260923_claude_reset_grants/010_plan.md similarity index 100% rename from devlog/_plan/260923_claude_reset_grants/010_plan.md rename to devlog/_fin/260923_claude_reset_grants/010_plan.md diff --git a/devlog/_plan/260923_configured_native_gpt_models/010_plan.md b/devlog/_fin/260923_configured_native_gpt_models/010_plan.md similarity index 100% rename from devlog/_plan/260923_configured_native_gpt_models/010_plan.md rename to devlog/_fin/260923_configured_native_gpt_models/010_plan.md diff --git a/devlog/_plan/260923_configured_native_gpt_models/020_audit.md b/devlog/_fin/260923_configured_native_gpt_models/020_audit.md similarity index 100% rename from devlog/_plan/260923_configured_native_gpt_models/020_audit.md rename to devlog/_fin/260923_configured_native_gpt_models/020_audit.md diff --git a/devlog/_plan/260923_docs_polish_404/000_plan.md b/devlog/_fin/260923_docs_polish_404/000_plan.md similarity index 100% rename from devlog/_plan/260923_docs_polish_404/000_plan.md rename to devlog/_fin/260923_docs_polish_404/000_plan.md diff --git a/devlog/_plan/260923_docs_polish_404/001_existing_link_failures.md b/devlog/_fin/260923_docs_polish_404/001_existing_link_failures.md similarity index 100% rename from devlog/_plan/260923_docs_polish_404/001_existing_link_failures.md rename to devlog/_fin/260923_docs_polish_404/001_existing_link_failures.md diff --git a/devlog/_plan/260923_docs_polish_404/010_wp2_link_integrity.md b/devlog/_fin/260923_docs_polish_404/010_wp2_link_integrity.md similarity index 100% rename from devlog/_plan/260923_docs_polish_404/010_wp2_link_integrity.md rename to devlog/_fin/260923_docs_polish_404/010_wp2_link_integrity.md diff --git a/devlog/_plan/260923_docs_polish_404/020_wp3_english_polish_readme_resync.md b/devlog/_fin/260923_docs_polish_404/020_wp3_english_polish_readme_resync.md similarity index 100% rename from devlog/_plan/260923_docs_polish_404/020_wp3_english_polish_readme_resync.md rename to devlog/_fin/260923_docs_polish_404/020_wp3_english_polish_readme_resync.md diff --git a/devlog/_plan/260923_docs_polish_404/021_wp3_macos_menu_bar_draft.md b/devlog/_fin/260923_docs_polish_404/021_wp3_macos_menu_bar_draft.md similarity index 100% rename from devlog/_plan/260923_docs_polish_404/021_wp3_macos_menu_bar_draft.md rename to devlog/_fin/260923_docs_polish_404/021_wp3_macos_menu_bar_draft.md diff --git a/devlog/_plan/260923_docs_polish_404/030_wp4_locale_coverage.md b/devlog/_fin/260923_docs_polish_404/030_wp4_locale_coverage.md similarity index 100% rename from devlog/_plan/260923_docs_polish_404/030_wp4_locale_coverage.md rename to devlog/_fin/260923_docs_polish_404/030_wp4_locale_coverage.md diff --git a/devlog/_plan/260923_docs_polish_404/040_wp5_delivery.md b/devlog/_fin/260923_docs_polish_404/040_wp5_delivery.md similarity index 100% rename from devlog/_plan/260923_docs_polish_404/040_wp5_delivery.md rename to devlog/_fin/260923_docs_polish_404/040_wp5_delivery.md diff --git a/devlog/_plan/260923_docs_retirement/000_plan.md b/devlog/_fin/260923_docs_retirement/000_plan.md similarity index 100% rename from devlog/_plan/260923_docs_retirement/000_plan.md rename to devlog/_fin/260923_docs_retirement/000_plan.md diff --git a/devlog/_plan/260923_docs_retirement/010_phase1_docs_retirement.md b/devlog/_fin/260923_docs_retirement/010_phase1_docs_retirement.md similarity index 100% rename from devlog/_plan/260923_docs_retirement/010_phase1_docs_retirement.md rename to devlog/_fin/260923_docs_retirement/010_phase1_docs_retirement.md diff --git a/devlog/_plan/260923_gpt6_catalog_lanes/000_plan.md b/devlog/_fin/260923_gpt6_catalog_lanes/000_plan.md similarity index 100% rename from devlog/_plan/260923_gpt6_catalog_lanes/000_plan.md rename to devlog/_fin/260923_gpt6_catalog_lanes/000_plan.md diff --git a/devlog/_plan/260923_gpt6_catalog_lanes/010_phase1_upstream_repin.md b/devlog/_fin/260923_gpt6_catalog_lanes/010_phase1_upstream_repin.md similarity index 100% rename from devlog/_plan/260923_gpt6_catalog_lanes/010_phase1_upstream_repin.md rename to devlog/_fin/260923_gpt6_catalog_lanes/010_phase1_upstream_repin.md diff --git a/devlog/_plan/260923_gpt6_catalog_lanes/020_phase2_roster_admission.md b/devlog/_fin/260923_gpt6_catalog_lanes/020_phase2_roster_admission.md similarity index 100% rename from devlog/_plan/260923_gpt6_catalog_lanes/020_phase2_roster_admission.md rename to devlog/_fin/260923_gpt6_catalog_lanes/020_phase2_roster_admission.md diff --git a/devlog/_plan/260923_gpt6_catalog_lanes/030_phase3_gpt6_rows.md b/devlog/_fin/260923_gpt6_catalog_lanes/030_phase3_gpt6_rows.md similarity index 100% rename from devlog/_plan/260923_gpt6_catalog_lanes/030_phase3_gpt6_rows.md rename to devlog/_fin/260923_gpt6_catalog_lanes/030_phase3_gpt6_rows.md diff --git a/devlog/_plan/260923_grok47_parity/000_plan.md b/devlog/_fin/260923_grok47_parity/000_plan.md similarity index 98% rename from devlog/_plan/260923_grok47_parity/000_plan.md rename to devlog/_fin/260923_grok47_parity/000_plan.md index 36e98e1cdba..d2dc1cf7bb2 100644 --- a/devlog/_plan/260923_grok47_parity/000_plan.md +++ b/devlog/_fin/260923_grok47_parity/000_plan.md @@ -57,7 +57,7 @@ Reflection: see "Reflection" below. 2. src/providers/registry/entries-core.ts, xai block: modelSupportsServiceTier, modelWireDefaults (oauth, responses inbound), modelInputModalities, preserveReasoningContentModels, modelReasoningEfforts [low..xhigh], modelDefaultReasoningEfforts high, modelContextWindows 500_000 — each with a grok-4.7 sibling of 4.6; comments cite - devlog/_plan/260923_grok47_parity/010_probe-evidence.md. Devin block: add "grok-4-7" after "grok-4-6" in models. + devlog/_fin/260923_grok47_parity/010_probe-evidence.md. Devin block: add "grok-4-7" after "grok-4-6" in models. OpenCode Go block: modelWireDefaults, modelReasoningEfforts, modelDefaultReasoningEfforts for grok-4.7. 3. src/adapters/devin/live-models.ts — DEVIN_MODEL_CONTEXT_WINDOWS "grok-4-7": 500_000; DEVIN_MODEL_EFFORTS (if it has per-model entries) "grok-4-7": low..max, default medium if a default map exists. diff --git a/devlog/_plan/260923_grok47_parity/010_probe-evidence.md b/devlog/_fin/260923_grok47_parity/010_probe-evidence.md similarity index 100% rename from devlog/_plan/260923_grok47_parity/010_probe-evidence.md rename to devlog/_fin/260923_grok47_parity/010_probe-evidence.md diff --git a/devlog/_plan/260923_luvs_l5_responses_usage/000_plan.md b/devlog/_fin/260923_luvs_l5_responses_usage/000_plan.md similarity index 100% rename from devlog/_plan/260923_luvs_l5_responses_usage/000_plan.md rename to devlog/_fin/260923_luvs_l5_responses_usage/000_plan.md diff --git a/devlog/_plan/260923_luvs_l5_responses_usage/010_phase1_small_units.md b/devlog/_fin/260923_luvs_l5_responses_usage/010_phase1_small_units.md similarity index 100% rename from devlog/_plan/260923_luvs_l5_responses_usage/010_phase1_small_units.md rename to devlog/_fin/260923_luvs_l5_responses_usage/010_phase1_small_units.md diff --git a/devlog/_plan/260923_luvs_l5_responses_usage/020_phase2_responses_sequence.md b/devlog/_fin/260923_luvs_l5_responses_usage/020_phase2_responses_sequence.md similarity index 100% rename from devlog/_plan/260923_luvs_l5_responses_usage/020_phase2_responses_sequence.md rename to devlog/_fin/260923_luvs_l5_responses_usage/020_phase2_responses_sequence.md diff --git a/devlog/_plan/260923_luvs_l5_responses_usage/030_phase3_search_usage.md b/devlog/_fin/260923_luvs_l5_responses_usage/030_phase3_search_usage.md similarity index 100% rename from devlog/_plan/260923_luvs_l5_responses_usage/030_phase3_search_usage.md rename to devlog/_fin/260923_luvs_l5_responses_usage/030_phase3_search_usage.md diff --git a/devlog/_plan/260923_luvs_l5_responses_usage/040_phase4_pr_ci_review.md b/devlog/_fin/260923_luvs_l5_responses_usage/040_phase4_pr_ci_review.md similarity index 100% rename from devlog/_plan/260923_luvs_l5_responses_usage/040_phase4_pr_ci_review.md rename to devlog/_fin/260923_luvs_l5_responses_usage/040_phase4_pr_ci_review.md diff --git a/devlog/_plan/260923_luvs_l5_responses_usage/050_phase5_close_originals.md b/devlog/_fin/260923_luvs_l5_responses_usage/050_phase5_close_originals.md similarity index 100% rename from devlog/_plan/260923_luvs_l5_responses_usage/050_phase5_close_originals.md rename to devlog/_fin/260923_luvs_l5_responses_usage/050_phase5_close_originals.md diff --git a/devlog/_plan/260923_models_catalog_delivery_disclosure/000_plan.md b/devlog/_fin/260923_models_catalog_delivery_disclosure/000_plan.md similarity index 100% rename from devlog/_plan/260923_models_catalog_delivery_disclosure/000_plan.md rename to devlog/_fin/260923_models_catalog_delivery_disclosure/000_plan.md diff --git a/devlog/_plan/260923_models_catalog_delivery_disclosure/010_architect.md b/devlog/_fin/260923_models_catalog_delivery_disclosure/010_architect.md similarity index 100% rename from devlog/_plan/260923_models_catalog_delivery_disclosure/010_architect.md rename to devlog/_fin/260923_models_catalog_delivery_disclosure/010_architect.md diff --git a/devlog/_plan/260923_models_catalog_delivery_disclosure/020_done.md b/devlog/_fin/260923_models_catalog_delivery_disclosure/020_done.md similarity index 100% rename from devlog/_plan/260923_models_catalog_delivery_disclosure/020_done.md rename to devlog/_fin/260923_models_catalog_delivery_disclosure/020_done.md diff --git a/devlog/_plan/260923_models_catalog_delivery_disclosure/evidence/ko-collapsed.png b/devlog/_fin/260923_models_catalog_delivery_disclosure/evidence/ko-collapsed.png similarity index 100% rename from devlog/_plan/260923_models_catalog_delivery_disclosure/evidence/ko-collapsed.png rename to devlog/_fin/260923_models_catalog_delivery_disclosure/evidence/ko-collapsed.png diff --git a/devlog/_plan/260923_models_catalog_delivery_disclosure/evidence/ko-expanded.png b/devlog/_fin/260923_models_catalog_delivery_disclosure/evidence/ko-expanded.png similarity index 100% rename from devlog/_plan/260923_models_catalog_delivery_disclosure/evidence/ko-expanded.png rename to devlog/_fin/260923_models_catalog_delivery_disclosure/evidence/ko-expanded.png diff --git a/devlog/_plan/260923_opus_5_5_catalog/010_plan.md b/devlog/_fin/260923_opus_5_5_catalog/010_plan.md similarity index 100% rename from devlog/_plan/260923_opus_5_5_catalog/010_plan.md rename to devlog/_fin/260923_opus_5_5_catalog/010_plan.md diff --git a/devlog/_plan/260923_opus_5_5_catalog/020_audit.md b/devlog/_fin/260923_opus_5_5_catalog/020_audit.md similarity index 100% rename from devlog/_plan/260923_opus_5_5_catalog/020_audit.md rename to devlog/_fin/260923_opus_5_5_catalog/020_audit.md diff --git a/devlog/_plan/260923_opus_5_5_catalog/030_done.md b/devlog/_fin/260923_opus_5_5_catalog/030_done.md similarity index 100% rename from devlog/_plan/260923_opus_5_5_catalog/030_done.md rename to devlog/_fin/260923_opus_5_5_catalog/030_done.md diff --git a/devlog/_plan/260923_opus_5_5_catalog/040_plan_providers.md b/devlog/_fin/260923_opus_5_5_catalog/040_plan_providers.md similarity index 100% rename from devlog/_plan/260923_opus_5_5_catalog/040_plan_providers.md rename to devlog/_fin/260923_opus_5_5_catalog/040_plan_providers.md diff --git a/devlog/_plan/260923_opus_5_5_catalog/050_done_providers.md b/devlog/_fin/260923_opus_5_5_catalog/050_done_providers.md similarity index 100% rename from devlog/_plan/260923_opus_5_5_catalog/050_done_providers.md rename to devlog/_fin/260923_opus_5_5_catalog/050_done_providers.md diff --git a/devlog/_plan/260923_opus_5_5_catalog/060_plan_preemptive.md b/devlog/_fin/260923_opus_5_5_catalog/060_plan_preemptive.md similarity index 100% rename from devlog/_plan/260923_opus_5_5_catalog/060_plan_preemptive.md rename to devlog/_fin/260923_opus_5_5_catalog/060_plan_preemptive.md diff --git a/devlog/_plan/260923_p1_provider_state_continuity/000_overview.md b/devlog/_fin/260923_p1_provider_state_continuity/000_overview.md similarity index 100% rename from devlog/_plan/260923_p1_provider_state_continuity/000_overview.md rename to devlog/_fin/260923_p1_provider_state_continuity/000_overview.md diff --git a/devlog/_plan/260923_p1_provider_state_continuity/010_part1_compat_contract.md b/devlog/_fin/260923_p1_provider_state_continuity/010_part1_compat_contract.md similarity index 100% rename from devlog/_plan/260923_p1_provider_state_continuity/010_part1_compat_contract.md rename to devlog/_fin/260923_p1_provider_state_continuity/010_part1_compat_contract.md diff --git a/devlog/_plan/260923_p1_provider_state_continuity/020_part2_shadow_target_lifecycle.md b/devlog/_fin/260923_p1_provider_state_continuity/020_part2_shadow_target_lifecycle.md similarity index 100% rename from devlog/_plan/260923_p1_provider_state_continuity/020_part2_shadow_target_lifecycle.md rename to devlog/_fin/260923_p1_provider_state_continuity/020_part2_shadow_target_lifecycle.md diff --git a/devlog/_plan/260923_p1_provider_state_continuity/030_delivery.md b/devlog/_fin/260923_p1_provider_state_continuity/030_delivery.md similarity index 100% rename from devlog/_plan/260923_p1_provider_state_continuity/030_delivery.md rename to devlog/_fin/260923_p1_provider_state_continuity/030_delivery.md diff --git a/devlog/_plan/260923_p2_update_continuity/000_roadmap.md b/devlog/_fin/260923_p2_update_continuity/000_roadmap.md similarity index 100% rename from devlog/_plan/260923_p2_update_continuity/000_roadmap.md rename to devlog/_fin/260923_p2_update_continuity/000_roadmap.md diff --git a/devlog/_plan/260923_p2_update_continuity/010_fenced_proxy_identity.md b/devlog/_fin/260923_p2_update_continuity/010_fenced_proxy_identity.md similarity index 100% rename from devlog/_plan/260923_p2_update_continuity/010_fenced_proxy_identity.md rename to devlog/_fin/260923_p2_update_continuity/010_fenced_proxy_identity.md diff --git a/devlog/_plan/260923_p2_update_continuity/020_updater_leftovers_and_guidance.md b/devlog/_fin/260923_p2_update_continuity/020_updater_leftovers_and_guidance.md similarity index 100% rename from devlog/_plan/260923_p2_update_continuity/020_updater_leftovers_and_guidance.md rename to devlog/_fin/260923_p2_update_continuity/020_updater_leftovers_and_guidance.md diff --git a/devlog/_plan/260923_p3_azure_opaque_recovery/000_roadmap.md b/devlog/_fin/260923_p3_azure_opaque_recovery/000_roadmap.md similarity index 100% rename from devlog/_plan/260923_p3_azure_opaque_recovery/000_roadmap.md rename to devlog/_fin/260923_p3_azure_opaque_recovery/000_roadmap.md diff --git a/devlog/_plan/260923_p3_azure_opaque_recovery/010_source.md b/devlog/_fin/260923_p3_azure_opaque_recovery/010_source.md similarity index 100% rename from devlog/_plan/260923_p3_azure_opaque_recovery/010_source.md rename to devlog/_fin/260923_p3_azure_opaque_recovery/010_source.md diff --git a/devlog/_plan/260923_p3_azure_opaque_recovery/020_tests.md b/devlog/_fin/260923_p3_azure_opaque_recovery/020_tests.md similarity index 100% rename from devlog/_plan/260923_p3_azure_opaque_recovery/020_tests.md rename to devlog/_fin/260923_p3_azure_opaque_recovery/020_tests.md diff --git a/devlog/_plan/260923_p3_azure_opaque_recovery/030_docs.md b/devlog/_fin/260923_p3_azure_opaque_recovery/030_docs.md similarity index 100% rename from devlog/_plan/260923_p3_azure_opaque_recovery/030_docs.md rename to devlog/_fin/260923_p3_azure_opaque_recovery/030_docs.md diff --git a/devlog/_plan/260923_p3_azure_opaque_recovery/040_delivery.md b/devlog/_fin/260923_p3_azure_opaque_recovery/040_delivery.md similarity index 100% rename from devlog/_plan/260923_p3_azure_opaque_recovery/040_delivery.md rename to devlog/_fin/260923_p3_azure_opaque_recovery/040_delivery.md diff --git a/devlog/_plan/260923_p3_azure_opaque_recovery/050_review_followup.md b/devlog/_fin/260923_p3_azure_opaque_recovery/050_review_followup.md similarity index 100% rename from devlog/_plan/260923_p3_azure_opaque_recovery/050_review_followup.md rename to devlog/_fin/260923_p3_azure_opaque_recovery/050_review_followup.md diff --git a/devlog/_plan/260923_p4_ci_pr_finish/000_plan.md b/devlog/_fin/260923_p4_ci_pr_finish/000_plan.md similarity index 100% rename from devlog/_plan/260923_p4_ci_pr_finish/000_plan.md rename to devlog/_fin/260923_p4_ci_pr_finish/000_plan.md diff --git a/devlog/_plan/260923_p4_ci_pr_finish/010_pr5469_privacy_gate.md b/devlog/_fin/260923_p4_ci_pr_finish/010_pr5469_privacy_gate.md similarity index 100% rename from devlog/_plan/260923_p4_ci_pr_finish/010_pr5469_privacy_gate.md rename to devlog/_fin/260923_p4_ci_pr_finish/010_pr5469_privacy_gate.md diff --git a/devlog/_plan/260923_p4_ci_pr_finish/020_pr5456_batch_deadline.md b/devlog/_fin/260923_p4_ci_pr_finish/020_pr5456_batch_deadline.md similarity index 100% rename from devlog/_plan/260923_p4_ci_pr_finish/020_pr5456_batch_deadline.md rename to devlog/_fin/260923_p4_ci_pr_finish/020_pr5456_batch_deadline.md diff --git a/devlog/_plan/260923_p4_ci_pr_finish/030_pr5471_latest_dev_wording.md b/devlog/_fin/260923_p4_ci_pr_finish/030_pr5471_latest_dev_wording.md similarity index 100% rename from devlog/_plan/260923_p4_ci_pr_finish/030_pr5471_latest_dev_wording.md rename to devlog/_fin/260923_p4_ci_pr_finish/030_pr5471_latest_dev_wording.md diff --git a/devlog/_plan/260923_p5_ci_release_gaps/000_plan.md b/devlog/_fin/260923_p5_ci_release_gaps/000_plan.md similarity index 100% rename from devlog/_plan/260923_p5_ci_release_gaps/000_plan.md rename to devlog/_fin/260923_p5_ci_release_gaps/000_plan.md diff --git a/devlog/_plan/260923_p5_ci_release_gaps/010_release_preflight.md b/devlog/_fin/260923_p5_ci_release_gaps/010_release_preflight.md similarity index 100% rename from devlog/_plan/260923_p5_ci_release_gaps/010_release_preflight.md rename to devlog/_fin/260923_p5_ci_release_gaps/010_release_preflight.md diff --git a/devlog/_plan/260923_p5_ci_release_gaps/020_release_outcome_report.md b/devlog/_fin/260923_p5_ci_release_gaps/020_release_outcome_report.md similarity index 100% rename from devlog/_plan/260923_p5_ci_release_gaps/020_release_outcome_report.md rename to devlog/_fin/260923_p5_ci_release_gaps/020_release_outcome_report.md diff --git a/devlog/_plan/260923_p5_ci_release_gaps/030_shard_balance.md b/devlog/_fin/260923_p5_ci_release_gaps/030_shard_balance.md similarity index 100% rename from devlog/_plan/260923_p5_ci_release_gaps/030_shard_balance.md rename to devlog/_fin/260923_p5_ci_release_gaps/030_shard_balance.md diff --git a/devlog/_plan/260923_p5_ci_release_gaps/040_scope_gap_checks.md b/devlog/_fin/260923_p5_ci_release_gaps/040_scope_gap_checks.md similarity index 100% rename from devlog/_plan/260923_p5_ci_release_gaps/040_scope_gap_checks.md rename to devlog/_fin/260923_p5_ci_release_gaps/040_scope_gap_checks.md diff --git a/devlog/_plan/260923_p5_ci_release_gaps/050_delivery.md b/devlog/_fin/260923_p5_ci_release_gaps/050_delivery.md similarity index 100% rename from devlog/_plan/260923_p5_ci_release_gaps/050_delivery.md rename to devlog/_fin/260923_p5_ci_release_gaps/050_delivery.md diff --git a/devlog/_plan/260923_subagent_roster_defaults_autosave/000_README.md b/devlog/_fin/260923_subagent_roster_defaults_autosave/000_README.md similarity index 100% rename from devlog/_plan/260923_subagent_roster_defaults_autosave/000_README.md rename to devlog/_fin/260923_subagent_roster_defaults_autosave/000_README.md diff --git a/devlog/_plan/260923_subagent_roster_defaults_autosave/010_gpt6_default_roster.md b/devlog/_fin/260923_subagent_roster_defaults_autosave/010_gpt6_default_roster.md similarity index 100% rename from devlog/_plan/260923_subagent_roster_defaults_autosave/010_gpt6_default_roster.md rename to devlog/_fin/260923_subagent_roster_defaults_autosave/010_gpt6_default_roster.md diff --git a/devlog/_plan/260923_subagent_roster_defaults_autosave/020_roster_autosave.md b/devlog/_fin/260923_subagent_roster_defaults_autosave/020_roster_autosave.md similarity index 100% rename from devlog/_plan/260923_subagent_roster_defaults_autosave/020_roster_autosave.md rename to devlog/_fin/260923_subagent_roster_defaults_autosave/020_roster_autosave.md diff --git a/devlog/_plan/260923_subagent_roster_defaults_autosave/030_delivery.md b/devlog/_fin/260923_subagent_roster_defaults_autosave/030_delivery.md similarity index 100% rename from devlog/_plan/260923_subagent_roster_defaults_autosave/030_delivery.md rename to devlog/_fin/260923_subagent_roster_defaults_autosave/030_delivery.md diff --git a/devlog/_plan/260923_subagent_roster_defaults_autosave/040_legacy_roster_cleanup.md b/devlog/_fin/260923_subagent_roster_defaults_autosave/040_legacy_roster_cleanup.md similarity index 100% rename from devlog/_plan/260923_subagent_roster_defaults_autosave/040_legacy_roster_cleanup.md rename to devlog/_fin/260923_subagent_roster_defaults_autosave/040_legacy_roster_cleanup.md diff --git a/devlog/_plan/260923_usage_timeline_pool_merge/000_plan.md b/devlog/_fin/260923_usage_timeline_pool_merge/000_plan.md similarity index 100% rename from devlog/_plan/260923_usage_timeline_pool_merge/000_plan.md rename to devlog/_fin/260923_usage_timeline_pool_merge/000_plan.md diff --git a/devlog/_plan/260923_usage_timeline_pool_merge/010_phase1.md b/devlog/_fin/260923_usage_timeline_pool_merge/010_phase1.md similarity index 100% rename from devlog/_plan/260923_usage_timeline_pool_merge/010_phase1.md rename to devlog/_fin/260923_usage_timeline_pool_merge/010_phase1.md diff --git a/devlog/_plan/260924_anthropic_fast_opt_in/010_plan.md b/devlog/_fin/260924_anthropic_fast_opt_in/010_plan.md similarity index 97% rename from devlog/_plan/260924_anthropic_fast_opt_in/010_plan.md rename to devlog/_fin/260924_anthropic_fast_opt_in/010_plan.md index 58a8a63a87e..511c2dbc3c7 100644 --- a/devlog/_plan/260924_anthropic_fast_opt_in/010_plan.md +++ b/devlog/_fin/260924_anthropic_fast_opt_in/010_plan.md @@ -5,7 +5,7 @@ - Request (2026-09-24): Anthropic fast mode spends usage credits, so keep it off by default; give the Anthropic provider card on the dashboard Models page one row that turns it on and off; open a PR and merge it. Push, PR, and merge are authorized for this change. -- Previous unit: devlog/_plan/260923_anthropic_fast_speed (#5604) made `claude-opus-5-5`, `claude-opus-5`, +- Previous unit: devlog/_fin/260923_anthropic_fast_speed (#5604) made `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8` Fast-eligible on `anthropic` and `anthropic-apikey` with no switch other than the global `fastMode`. Its residual already named the cost: every turn on an account without credits pays a refused round trip, and an account with credits is billed 2x without a per-provider choice. diff --git a/devlog/_plan/260924_anthropic_forced_tool_choice/010_plan.md b/devlog/_fin/260924_anthropic_forced_tool_choice/010_plan.md similarity index 100% rename from devlog/_plan/260924_anthropic_forced_tool_choice/010_plan.md rename to devlog/_fin/260924_anthropic_forced_tool_choice/010_plan.md diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/000_plan.md b/devlog/_fin/260924_claude_desktop_picker_mode/000_plan.md similarity index 100% rename from devlog/_plan/260924_claude_desktop_picker_mode/000_plan.md rename to devlog/_fin/260924_claude_desktop_picker_mode/000_plan.md diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/001_research.md b/devlog/_fin/260924_claude_desktop_picker_mode/001_research.md similarity index 100% rename from devlog/_plan/260924_claude_desktop_picker_mode/001_research.md rename to devlog/_fin/260924_claude_desktop_picker_mode/001_research.md diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/010_wp2_gateway_default_and_warning.md b/devlog/_fin/260924_claude_desktop_picker_mode/010_wp2_gateway_default_and_warning.md similarity index 100% rename from devlog/_plan/260924_claude_desktop_picker_mode/010_wp2_gateway_default_and_warning.md rename to devlog/_fin/260924_claude_desktop_picker_mode/010_wp2_gateway_default_and_warning.md diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/020_wp3_picker_core.md b/devlog/_fin/260924_claude_desktop_picker_mode/020_wp3_picker_core.md similarity index 100% rename from devlog/_plan/260924_claude_desktop_picker_mode/020_wp3_picker_core.md rename to devlog/_fin/260924_claude_desktop_picker_mode/020_wp3_picker_core.md diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/030_wp4_picker_activation.md b/devlog/_fin/260924_claude_desktop_picker_mode/030_wp4_picker_activation.md similarity index 100% rename from devlog/_plan/260924_claude_desktop_picker_mode/030_wp4_picker_activation.md rename to devlog/_fin/260924_claude_desktop_picker_mode/030_wp4_picker_activation.md diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/040_wp5_live_proof_pr_merge.md b/devlog/_fin/260924_claude_desktop_picker_mode/040_wp5_live_proof_pr_merge.md similarity index 100% rename from devlog/_plan/260924_claude_desktop_picker_mode/040_wp5_live_proof_pr_merge.md rename to devlog/_fin/260924_claude_desktop_picker_mode/040_wp5_live_proof_pr_merge.md diff --git a/devlog/_plan/260924_cursor_fast_pricing/000_plan.md b/devlog/_fin/260924_cursor_fast_pricing/000_plan.md similarity index 100% rename from devlog/_plan/260924_cursor_fast_pricing/000_plan.md rename to devlog/_fin/260924_cursor_fast_pricing/000_plan.md diff --git a/devlog/_plan/260924_cursor_fast_pricing/010_implementation.md b/devlog/_fin/260924_cursor_fast_pricing/010_implementation.md similarity index 100% rename from devlog/_plan/260924_cursor_fast_pricing/010_implementation.md rename to devlog/_fin/260924_cursor_fast_pricing/010_implementation.md diff --git a/devlog/_plan/260924_cursor_fast_pricing/020_verification.md b/devlog/_fin/260924_cursor_fast_pricing/020_verification.md similarity index 100% rename from devlog/_plan/260924_cursor_fast_pricing/020_verification.md rename to devlog/_fin/260924_cursor_fast_pricing/020_verification.md diff --git a/devlog/_plan/260924_l3_provider_adapters/010_plan.md b/devlog/_fin/260924_l3_provider_adapters/010_plan.md similarity index 100% rename from devlog/_plan/260924_l3_provider_adapters/010_plan.md rename to devlog/_fin/260924_l3_provider_adapters/010_plan.md diff --git a/devlog/_plan/260924_l4_codex_cli_service/000_roadmap.md b/devlog/_fin/260924_l4_codex_cli_service/000_roadmap.md similarity index 100% rename from devlog/_plan/260924_l4_codex_cli_service/000_roadmap.md rename to devlog/_fin/260924_l4_codex_cli_service/000_roadmap.md diff --git a/devlog/_plan/260924_l4_codex_cli_service/010_carry_ready_prs.md b/devlog/_fin/260924_l4_codex_cli_service/010_carry_ready_prs.md similarity index 100% rename from devlog/_plan/260924_l4_codex_cli_service/010_carry_ready_prs.md rename to devlog/_fin/260924_l4_codex_cli_service/010_carry_ready_prs.md diff --git a/devlog/_plan/260924_l4_codex_cli_service/020_subagent_identity_5221.md b/devlog/_fin/260924_l4_codex_cli_service/020_subagent_identity_5221.md similarity index 100% rename from devlog/_plan/260924_l4_codex_cli_service/020_subagent_identity_5221.md rename to devlog/_fin/260924_l4_codex_cli_service/020_subagent_identity_5221.md diff --git a/devlog/_plan/260924_l4_codex_cli_service/030_agent_message_recovery_5009.md b/devlog/_fin/260924_l4_codex_cli_service/030_agent_message_recovery_5009.md similarity index 100% rename from devlog/_plan/260924_l4_codex_cli_service/030_agent_message_recovery_5009.md rename to devlog/_fin/260924_l4_codex_cli_service/030_agent_message_recovery_5009.md diff --git a/devlog/_plan/260924_l4_codex_cli_service/040_main_hard_lock_default_5694.md b/devlog/_fin/260924_l4_codex_cli_service/040_main_hard_lock_default_5694.md similarity index 100% rename from devlog/_plan/260924_l4_codex_cli_service/040_main_hard_lock_default_5694.md rename to devlog/_fin/260924_l4_codex_cli_service/040_main_hard_lock_default_5694.md diff --git a/devlog/_plan/260924_l4_codex_cli_service/050_publish.md b/devlog/_fin/260924_l4_codex_cli_service/050_publish.md similarity index 90% rename from devlog/_plan/260924_l4_codex_cli_service/050_publish.md rename to devlog/_fin/260924_l4_codex_cli_service/050_publish.md index 89e1484903c..64ef6196de6 100644 --- a/devlog/_plan/260924_l4_codex_cli_service/050_publish.md +++ b/devlog/_fin/260924_l4_codex_cli_service/050_publish.md @@ -3,7 +3,7 @@ Rebase on origin/dev; bun run typecheck; focused tests for every item; bun run t ## wp5 P (executable) - origin/dev moved to df61bcecd5 (#5737, #5738). Overlapping files: docs-site reference/configuration/providers.md, scripts/test-layout/layout.json, structure/config.md, structure/subagents.md, tests/fixtures/test-layout-expected.json. Resolve by keeping both sides' entries. -- Before rebase: git mv devlog/_plan/260924_l4_codex_cli_service -> devlog/_fin/ is deferred until after merge (unit stays open while the PR is open). +- Before rebase: git mv devlog/_fin/260924_l4_codex_cli_service -> devlog/_fin/ is deferred until after merge (unit stays open while the PR is open). - Validation after rebase: bun run typecheck; focused union of all item tests; bun run test:changed (worktree under /Users/jun/.codex trips the test-home guard for some temp-dir tests — compare any failure with a pristine git archive copy); bun run privacy:scan; bun run structure:check; bun run lint:gui. - GUI screenshot: run the dashboard (bun run src/cli/index.ts start on a spare port with a throwaway OPENCODEX_HOME), open Codex settings > Multi-auth > Advanced, capture the 98% card to .tmp/. Hosting: pr-assets push only if the user authorizes; otherwise the coordinator uploads it. - PR body: Summary per item with Carries/Closes lines, Security review for #5713, Verification with commands, Checklist, coordinator decisions (Reserve trade-off, previous opt-outs re-enabled, stale connect marker retention, 64 MiB indeterminate). diff --git a/devlog/_plan/260924_regression_risk_fixes/000_overview.md b/devlog/_fin/260924_regression_risk_fixes/000_overview.md similarity index 100% rename from devlog/_plan/260924_regression_risk_fixes/000_overview.md rename to devlog/_fin/260924_regression_risk_fixes/000_overview.md diff --git a/devlog/_plan/260924_regression_risk_fixes/010_wsl_home.md b/devlog/_fin/260924_regression_risk_fixes/010_wsl_home.md similarity index 100% rename from devlog/_plan/260924_regression_risk_fixes/010_wsl_home.md rename to devlog/_fin/260924_regression_risk_fixes/010_wsl_home.md diff --git a/devlog/_plan/260924_regression_risk_fixes/020_windows_mise_node.md b/devlog/_fin/260924_regression_risk_fixes/020_windows_mise_node.md similarity index 100% rename from devlog/_plan/260924_regression_risk_fixes/020_windows_mise_node.md rename to devlog/_fin/260924_regression_risk_fixes/020_windows_mise_node.md diff --git a/devlog/_plan/260924_regression_risk_fixes/030_echo_filter.md b/devlog/_fin/260924_regression_risk_fixes/030_echo_filter.md similarity index 100% rename from devlog/_plan/260924_regression_risk_fixes/030_echo_filter.md rename to devlog/_fin/260924_regression_risk_fixes/030_echo_filter.md diff --git a/devlog/_plan/260924_regression_risk_fixes/040_tool_call_hold.md b/devlog/_fin/260924_regression_risk_fixes/040_tool_call_hold.md similarity index 100% rename from devlog/_plan/260924_regression_risk_fixes/040_tool_call_hold.md rename to devlog/_fin/260924_regression_risk_fixes/040_tool_call_hold.md diff --git a/devlog/_plan/260924_regression_risk_fixes/050_delivery.md b/devlog/_fin/260924_regression_risk_fixes/050_delivery.md similarity index 100% rename from devlog/_plan/260924_regression_risk_fixes/050_delivery.md rename to devlog/_fin/260924_regression_risk_fixes/050_delivery.md diff --git a/devlog/_plan/260924_update_indicator/000_plan.md b/devlog/_fin/260924_update_indicator/000_plan.md similarity index 100% rename from devlog/_plan/260924_update_indicator/000_plan.md rename to devlog/_fin/260924_update_indicator/000_plan.md diff --git a/devlog/_plan/260924_update_indicator/001_sol_plan_draft.md b/devlog/_fin/260924_update_indicator/001_sol_plan_draft.md similarity index 100% rename from devlog/_plan/260924_update_indicator/001_sol_plan_draft.md rename to devlog/_fin/260924_update_indicator/001_sol_plan_draft.md diff --git a/devlog/_plan/260924_update_indicator/002_architect_proposal.md b/devlog/_fin/260924_update_indicator/002_architect_proposal.md similarity index 100% rename from devlog/_plan/260924_update_indicator/002_architect_proposal.md rename to devlog/_fin/260924_update_indicator/002_architect_proposal.md diff --git a/devlog/_plan/260924_update_indicator/010_phase1_package_cache.md b/devlog/_fin/260924_update_indicator/010_phase1_package_cache.md similarity index 100% rename from devlog/_plan/260924_update_indicator/010_phase1_package_cache.md rename to devlog/_fin/260924_update_indicator/010_phase1_package_cache.md diff --git a/devlog/_plan/260924_update_indicator/020_phase2_desktop_state_icons.md b/devlog/_fin/260924_update_indicator/020_phase2_desktop_state_icons.md similarity index 99% rename from devlog/_plan/260924_update_indicator/020_phase2_desktop_state_icons.md rename to devlog/_fin/260924_update_indicator/020_phase2_desktop_state_icons.md index be8a608e57b..9e4eae2e4d6 100644 --- a/devlog/_plan/260924_update_indicator/020_phase2_desktop_state_icons.md +++ b/devlog/_fin/260924_update_indicator/020_phase2_desktop_state_icons.md @@ -1265,7 +1265,7 @@ The commands below record the earlier *pre-B* runs on 2026-09-24 after root and | bun test tests/server/sidebar-routes.test.ts tests/server/management-route-registry.test.ts tests/ci-workflows/build-desktop-icon-set.test.ts tests/cli/cli-capabilities.test.ts (repository root) | 0 | 52 pass, 0 fail across four files; existing source only, no plan read. | | bun test tests/test-layout-tooling.test.ts (repository root) | 0 | 16 pass, 0 fail; reads existing layout JSON and fixture, not the plan. | | bun test tests/desktop-shell.test.ts (gui/) | 0 | 3 pass, 0 fail; reads existing GUI helper, not the plan. | -| bun -e 'const text = require("node:fs").readFileSync("devlog/_plan/260924_update_indicator/020_phase2_desktop_state_icons.md", "utf8"); const transpiler = new Bun.Transpiler({ loader: "ts" }); for (const marker of ["### New src/update/desktop-badge.ts", "NEW tests/update/update-desktop-badge.test.ts"]) { const part = text.slice(text.indexOf(marker)); const match = part.match(/~~~ts\n([\s\S]*?)\n~~~/); if (!match) throw new Error("missing " + marker); transpiler.transformSync(match[1]); } console.log("2 complete new TypeScript blocks parse");' (repository root) | 0 | Both complete new TypeScript blocks parsed; this command reads this plan directly. It is syntax evidence, not typechecking or behavior. | +| bun -e 'const text = require("node:fs").readFileSync("devlog/_fin/260924_update_indicator/020_phase2_desktop_state_icons.md", "utf8"); const transpiler = new Bun.Transpiler({ loader: "ts" }); for (const marker of ["### New src/update/desktop-badge.ts", "NEW tests/update/update-desktop-badge.test.ts"]) { const part = text.slice(text.indexOf(marker)); const match = part.match(/~~~ts\n([\s\S]*?)\n~~~/); if (!match) throw new Error("missing " + marker); transpiler.transformSync(match[1]); } console.log("2 complete new TypeScript blocks parse");' (repository root) | 0 | Both complete new TypeScript blocks parsed; this command reads this plan directly. It is syntax evidence, not typechecking or behavior. | | bun run icons:check (desktop/) | 0 | 18 generated icons match existing source; reads icon generator/assets, not the plan. The earlier trial spelling bun --cwd desktop run icons:check printed Bun usage with exit 0 and was not treated as a verifier. | | cargo test --manifest-path desktop/src-tauri/Cargo.toml (repository root) | 101 | The earlier run stopped before Rust tests because binaries/ocx-aarch64-apple-darwin was absent. This is environment setup, not a Rust test result. CI prepares an empty host-triple placeholder; B must do the same locally before retesting. | | swift -e 'import AppKit; let b = NSStatusBarButton(); print(b.cell?.imageRect(forBounds: b.bounds) as Any)' (repository root) | 0 | Confirmed the existing AppKit imageRect API resolves; this is not an overlay visual test and does not read plan. | diff --git a/devlog/_plan/260924_update_indicator/030_phase3_desktop_update_page.md b/devlog/_fin/260924_update_indicator/030_phase3_desktop_update_page.md similarity index 98% rename from devlog/_plan/260924_update_indicator/030_phase3_desktop_update_page.md rename to devlog/_fin/260924_update_indicator/030_phase3_desktop_update_page.md index 2e555612b60..9fd83af2870 100644 --- a/devlog/_plan/260924_update_indicator/030_phase3_desktop_update_page.md +++ b/devlog/_fin/260924_update_indicator/030_phase3_desktop_update_page.md @@ -967,9 +967,9 @@ After B, run `bun test ./gui/tests/desktop-shell.test.ts ./tests/clients/desktop The focused baseline tests do not read this document or the proposed page, so their result establishes only current conventions. -The docs-only target was checked directly with `bun -e 'import { readFileSync } from "node:fs"; import { scanText } from "./scripts/privacy-scan.ts"; const path = "devlog/_plan/260924_update_indicator/030_phase3_desktop_update_page.md"; const findings = scanText(path, readFileSync(path, "utf8")); console.log(`target privacy findings: ${findings.length}`); if (findings.length) process.exit(1)'`: exit 0, zero findings. This command reads this file. Repeat `bun run privacy:scan` after main stages the plan so the tracked-file walk includes it. +The docs-only target was checked directly with `bun -e 'import { readFileSync } from "node:fs"; import { scanText } from "./scripts/privacy-scan.ts"; const path = "devlog/_fin/260924_update_indicator/030_phase3_desktop_update_page.md"; const findings = scanText(path, readFileSync(path, "utf8")); console.log(`target privacy findings: ${findings.length}`); if (findings.length) process.exit(1)'`: exit 0, zero findings. This command reads this file. Repeat `bun run privacy:scan` after main stages the plan so the tracked-file walk includes it. -The proposed page script was parsed directly from this document with `bun -e 'import { readFileSync } from "node:fs"; import { Script } from "node:vm"; const text = readFileSync("devlog/_plan/260924_update_indicator/030_phase3_desktop_update_page.md", "utf8"); const html = text.match(/### NEW `desktop\/ui\/update\.html`[^]*?```html\n([^]*?)\n```/)?.[1]; if (!html) throw new Error("page block missing"); const script = html.match(/ + + +``` + +## Complete NEW file: `desktop/ui/cli.js` + +```javascript +"use strict"; +const invoke = window.__TAURI__?.core?.invoke; +const nodes = Object.fromEntries(["enabled", "state", "target", "issues", "error", "repair", "remove", "back", "titlebar"] + .map(id => [id, document.getElementById(id)])); +let busy = false; +let latest = null; +let polling = false; +function controls() { + nodes.enabled.disabled = busy || !invoke; + nodes.repair.disabled = busy || !invoke || !latest?.enabled; + nodes.remove.disabled = busy || !invoke; + nodes.back.disabled = busy || !invoke; +} +function render(s) { + latest = s; + nodes.enabled.checked = Boolean(s.enabled); + nodes.state.textContent = { + unobserved: "Configuration has not been inspected yet.", + configured: "Desktop command configuration is installed. Check selection in a new terminal.", + partial: "Some configuration could not be applied. Check the issues below.", + disabled: "Automatic terminal command configuration is off.", + blocked: "Configuration is blocked. Correct the reported issue and try Repair.", + }[s.phase] || "Configuration state is unavailable."; + nodes.target.textContent = s.expectedExecutable ? "Bundled executable: " + s.expectedExecutable : ""; + nodes.issues.replaceChildren(...(s.issues || []).map(code => { + const li = document.createElement("li"); li.textContent = code; return li; + })); + controls(); +} +function bounded(work) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Desktop did not answer within 30 seconds. Refresh status before retrying.")), 30000); + Promise.resolve(work).then(v => { clearTimeout(timer); resolve(v); }, e => { clearTimeout(timer); reject(e); }); + }); +} +async function refresh() { + if (!invoke || busy || polling) return; + polling = true; + try { render(await bounded(invoke("cli_status"))); } + catch (e) { nodes.error.textContent = String(e); nodes.error.hidden = false; } + finally { polling = false; } +} +async function action(name, args) { + if (!invoke || busy) return; + busy = true; controls(); nodes.error.hidden = true; + try { + const s = await bounded(args === undefined ? invoke(name) : invoke(name, args)); + if (s) render(s); + } catch (e) { + nodes.error.textContent = String(e); nodes.error.hidden = false; + } finally { busy = false; controls(); await refresh(); } +} +nodes.enabled.addEventListener("change", () => action("cli_set_enabled", { enabled: nodes.enabled.checked })); +nodes.repair.addEventListener("click", () => action("cli_install")); +nodes.remove.addEventListener("click", () => action("cli_remove")); +// Reuse the existing path-free dashboard command; only four NEW commands are introduced. +nodes.back.addEventListener("click", () => action("show_dashboard")); +if (invoke && typeof navigator !== "undefined" && /Macintosh/.test(navigator.userAgent)) { + nodes.titlebar.hidden = false; + nodes.titlebar.addEventListener("mousedown", e => { + if (e.button === 0) invoke("plugin:window|start_dragging").catch(() => {}); + }); + nodes.titlebar.addEventListener("dblclick", () => invoke("plugin:window|toggle_maximize").catch(() => {})); +} +if (!invoke) { + nodes.state.textContent = "Open this page from OpenCodex Desktop's Terminal command menu."; + controls(); +} else { + refresh(); + setInterval(refresh, 2000); +} +``` + +UI never passes a file path, command line, registry key, executable target or URL to Rust. Status strings use textContent, not HTML. A frontend deadline does not cancel a blocking operation; a retry serializes behind the operation, and status polling does not launch another write. Process death while pending is handled by journal recovery on next launch. + +## Complete NEW file: `tests/clients/desktop-cli-command-surface.test.ts` + +```typescript +import { describe, expect, test } from "bun:test"; +import { readFileSync, mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { runInNewContext } from "node:vm"; +import { repoPath } from "../helpers/repo-root"; +import { initializeNodeLauncherContext } from "../../src/cli/launcher-context"; +const read = (p: string) => readFileSync(repoPath(p), "utf8"); +const lib = read("desktop/src-tauri/src/lib.rs"); +const policy = read("desktop/src-tauri/src/window.rs"); +const page = read("desktop/ui/cli.html"); +const script = read("desktop/ui/cli.js"); +const posix = read("desktop/src-tauri/src/cli_command_posix.rs"); +const commands = ["cli_status", "cli_set_enabled", "cli_install", "cli_remove"]; +function harness(invoke?: (name: string, args?: unknown) => Promise) { + const handlers = new Map void>(); + const nodes = new Map(["enabled", "state", "target", "issues", "error", "repair", "remove", "back", "titlebar"].map(id => [id, { + checked: false, disabled: false, hidden: false, textContent: "", children: [] as unknown[], + addEventListener: (event: string, cb: () => void) => { handlers.set(id + ":" + event, cb); }, + replaceChildren(...items: unknown[]) { this.children = items; }, + }])); + runInNewContext(script, { + window: { __TAURI__: invoke ? { core: { invoke } } : undefined }, + document: { getElementById: (id: string) => nodes.get(id), createElement: () => ({ textContent: "" }) }, + setTimeout: () => 1, clearTimeout: () => {}, setInterval: () => 1, Promise, Error, + }); + return { nodes, handlers }; +} +async function settle() { for (let i = 0; i < 12; i++) await Promise.resolve(); } +describe("desktop CLI command surface", () => { + test("registers four path-free commands and guards every wrapper", () => { + const registered = lib.match(/generate_handler!\[([\s\S]*?)\]/)?.[1] || ""; + for (const name of commands) { + expect(registered).toContain(name); + const body = lib.match(new RegExp("(?:async )?fn " + name + "\\([\\s\\S]*?(?=\\n#\\[tauri::command\\]|\\npub fn run)"))?.[0] || ""; + expect(body).toContain("window::require_cli_page(&window)?"); + expect(body).not.toMatch(/(?:path|command|executable|registry_key):\s*(?:String|PathBuf)/); + } + expect(policy).toContain('url.path() == "/cli.html"'); + expect(policy).toContain('window.label() != "main"'); + expect(page).toContain(''); + expect(read("desktop/src-tauri/tauri.conf.json")).toContain('"frontendDist": "../ui"'); + }); + test("UI never sends an arbitrary path and only toggles a boolean", async () => { + const calls: Array<[string, unknown]> = []; + const { nodes, handlers } = harness((name, args) => { + calls.push([name, args]); + return Promise.resolve({ enabled: true, phase: "configured", expectedExecutable: "/example/ocx", issues: [] }); + }); + await settle(); + nodes.get("enabled")!.checked = false; + handlers.get("enabled:change")!(); await settle(); + handlers.get("repair:click")!(); await settle(); + handlers.get("remove:click")!(); await settle(); + for (const [name, args] of calls) { + if (name === "cli_set_enabled") expect(args).toEqual({ enabled: false }); + else expect(args).toBeUndefined(); + } + expect(calls.some(([n]) => n === "cli_install")).toBe(true); + expect(calls.some(([n]) => n === "cli_remove")).toBe(true); + expect(script).not.toContain("innerHTML"); + expect(page).not.toMatch(/]*type="(?:text|file)"/); + }); + test("missing Tauri disables controls and states the entry point", () => { + const { nodes } = harness(); + for (const id of ["enabled", "repair", "remove", "back"]) expect(nodes.get(id)!.disabled).toBe(true); + expect(nodes.get("state")!.textContent).toContain("Terminal command menu"); + }); + test("ready completion preserves CLI settings during launch and recovery", () => { + const startup = read("desktop/src-tauri/src/startup.rs"); + expect(startup).toContain("crate::window::shows_cli_page(&window)"); + expect(startup).toContain("|| keeps_update_page(mode, crate::window::shows_update_page(&window))"); + expect(startup).toMatch(/adopt_launch_origin_argument\(app\);\s*crate::cli_command::reconcile_on_launch\(app\);/); + expect(read("desktop/src-tauri/src/cli_command.rs")).toContain("spawn_blocking"); + expect(read("desktop/src-tauri/src/tray.rs")).toContain('"terminal-command" => crate::cli_command::show_page(app)'); + }); +}); +const shellTest = process.platform === "win32" ? test.skip : test; +function runShim(modify: (s: string) => string = s => s) { + const root = mkdtempSync(join(tmpdir(), "ocx-shim-proof-")); + try { + const cli = join(root, "fake ocx"); const shim = join(root, "ocx"); + writeFileSync(cli, '#!/bin/sh\nprintf "%s\\n" "${OCX_NODE_LAUNCH_CONTEXT-}" "${1-}" "${2-}"\n'); chmodSync(cli, 0o700); + const raw = posix.match(/const SHIM: &str = r#"([\s\S]*?)"#;/)?.[1]; + if (!raw) throw new Error("shim template absent"); + writeFileSync(shim, modify(raw.replace("@OWNER@", "00000000-0000-4000-8000-000000000001") + .replace("@CLI@", "'" + cli.replaceAll("'", "'\"'\"'") + "'"))); chmodSync(shim, 0o700); + const env = { ...process.env, ANTHROPIC_API_KEY: "shell-value", ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_BASE_URL: "https://example.invalid", OCX_NODE_LAUNCH_CONTEXT: "untrusted-old-context" }; + const child = spawnSync("/bin/sh", [shim, "claude"], { env, encoding: "utf8" }); + expect(child.status).toBe(0); return child.stdout.split("\n"); + } finally { rmSync(root, { recursive: true, force: true }); } +} +shellTest("shim proof is accepted by the actual initializer and captures only nonempty exports", () => { + const [raw, proof, command] = runShim(); + const argv = ["ocx", "entry", proof!, command!]; + const env = { OCX_NODE_LAUNCH_CONTEXT: raw }; + const context = initializeNodeLauncherContext(argv, env); + expect(context?.anthropicEnvSlots).toEqual(["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"]); + expect(context?.codexCliInspectionEnv).toBeNull(); + expect(argv).toEqual(["ocx", "entry", "claude"]); + expect(raw).not.toContain("shell-value"); + expect(raw).not.toContain("https://example.invalid"); +}); +shellTest("missing od falls back without inherited proof or context", () => { + const [raw, command] = runShim(s => s.replaceAll("/usr/bin/od", "/not-present/ocx-od")); + expect(raw).toBe(""); expect(command).toBe("claude"); +}); +shellTest("short random read falls back without context", () => { + const [raw, command] = runShim(s => s.replace("-N32", "-N31")); + expect(raw).toBe(""); expect(command).toBe("claude"); +}); +``` + +### Exact test-layout MODIFY diffs + +```diff +--- a/scripts/test-layout/layout.json ++++ b/scripts/test-layout/layout.json +@@ explicit, before desktop-cli-contracts.test.ts (base line 850) ++ "desktop-cli-command-surface.test.ts": "clients", + "desktop-cli-contracts.test.ts": "clients", +--- a/tests/fixtures/test-layout-expected.json ++++ b/tests/fixtures/test-layout-expected.json +@@ before desktop-cli-contracts.test.ts (base line 539) ++ "desktop-cli-command-surface.test.ts": "clients", + "desktop-cli-contracts.test.ts": "clients", "desktop-exit-ownership.test.ts": "clients", +``` + +The filesystem path is `tests/clients/desktop-cli-command-surface.test.ts`; registration keys are basenames, not full paths. Run both root layout guards explicitly because source-read contract tests and JSON registrations are not discovered solely by import graph. + +## Rust in-module tests and activation grounding + +The NEW Rust files consist of the production blocks above plus the applicable cfg(test) block below. Their syntax is checked from stdin; type correctness and Clippy are intentionally unverified here. Additional required acceptance cases below are named Rust test requirements, as requested; their platform/fixture seams must be implemented and their assertions reviewed before wp1 readiness. No new dev dependency: use an RAII tempdir built from `std::env::temp_dir()` and UUID, so every filesystem test runs under a disposable user-owned directory and removes it on Drop. Additional packaged/native acceptance and independent reviews belong to 030. + +Append to `cli_command_record.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { let p = fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("ocx-cli-{}", Uuid::new_v4())); private_dir(&p).unwrap(); Self(p) } + fn store(&self) -> Store { Store::open(self.0.join("record"), vec![self.0.join(".zshrc")]).unwrap() } + } + impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } + #[test] + fn record_roundtrip_and_unknown_version_refusal() { + let t = Temp::new(); let s = t.store(); let r = Record::fresh("installation".into()); + s.save(&r).unwrap(); assert_eq!(s.read().unwrap(), Some(r.clone())); + let mut bad = r; bad.version = 2; assert_eq!(s.validate(&bad, false).unwrap_err(), "record-invalid"); + } + #[test] + fn corrupt_record_does_not_become_first_run() { + let t = Temp::new(); let s = t.store(); + atomic(&s.root.join("cli.json"), None, b"{", 0o600).unwrap(); + assert_eq!(s.read().unwrap_err(), "record-invalid"); + } + #[test] + fn lock_serializes_and_is_released_on_close() { + let t = Temp::new(); let s = t.store(); + assert!(Store::open(s.root.clone(), s.rc_allowed.clone()).is_err()); + drop(s); let _ = t.store(); + } + #[test] + fn pending_journal_recovers_each_prefix_and_is_idempotent() { + for prefix in 0..=2 { + let t = Temp::new(); let s = t.store(); let mut r = Record::fresh("installation".into()); + let mut next = r.clone(); next.generation += 1; + let changes: Vec<_> = [s.root.join("bin/ocx"), s.root.join("path.sh")].into_iter().map(|p| Change { + kind: "file".into(), path: p.to_string_lossy().into_owned(), before: None, after: Some(b"new".to_vec()), mode: 0o600, backup_path: None, + }).collect(); + for c in changes.iter().take(prefix) { atomic(Path::new(&c.path), None, c.after.as_ref().unwrap(), 0o600).unwrap(); } + r.pending = Some(Journal { operation: "install".into(), changes, next: Box::new(next.clone()) }); s.save(&r).unwrap(); + s.recover(&mut r).unwrap(); assert_eq!(r, next); s.recover(&mut r).unwrap(); + } + } + #[test] + fn journal_conflict_preserves_user_edit_and_disabled_intent() { + let t = Temp::new(); let s = t.store(); let p = t.0.join(".zshrc"); + atomic(&p, None, b"user edit", 0o600).unwrap(); + let mut r = Record::fresh("installation".into()); r.enabled = false; + let mut next = r.clone(); next.generation += 1; + r.pending = Some(Journal { operation: "remove".into(), changes: vec![Change { + kind: "file".into(), path: p.to_string_lossy().into_owned(), before: Some(b"old".to_vec()), after: None, mode: 0o600, backup_path: None, + }], next: Box::new(next) }); s.save(&r).unwrap(); + assert_eq!(s.recover(&mut r).unwrap_err(), "journal-conflict"); + assert_eq!(fs::read(&p).unwrap(), b"user edit"); assert!(!s.read().unwrap().unwrap().enabled); + } + #[test] + fn journal_cannot_address_an_arbitrary_path() { + let t = Temp::new(); let s = t.store(); let mut r = Record::fresh("installation".into()); + let mut next = r.clone(); next.generation += 1; + r.pending = Some(Journal { operation: "install".into(), changes: vec![Change { + kind: "file".into(), path: t.0.join("unowned").to_string_lossy().into_owned(), before: None, after: Some(vec![]), mode: 0o600, backup_path: None, + }], next: Box::new(next) }); assert_eq!(s.validate(&r, false).unwrap_err(), "record-invalid"); + } + #[test] + fn atomic_write_detects_changed_original() { + let t = Temp::new(); let p = t.0.join("file"); atomic(&p, None, b"user", 0o600).unwrap(); + assert_eq!(atomic(&p, Some(b"old"), b"replacement", 0o600).unwrap_err(), "concurrent-edit"); + assert_eq!(fs::read(p).unwrap(), b"user"); + } + #[cfg(unix)] + #[test] + fn symlinks_special_files_and_readonly_files_are_refused() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let t = Temp::new(); let p = t.0.join("file"); atomic(&p, None, b"bytes", 0o600).unwrap(); + symlink(&p, t.0.join("link")).unwrap(); assert!(read_bytes(&t.0.join("link")).is_err()); + assert!(check(&t.0, false).is_err()); + fs::set_permissions(&p, fs::Permissions::from_mode(0o400)).unwrap(); assert!(read_bytes(&p).is_err()); + } +} +``` + +Append to `cli_command_posix.rs`: + +```rust +#[cfg(all(test, unix))] +mod tests { + use super::*; + fn root() -> PathBuf { fs::canonicalize(std::env::temp_dir()).unwrap().join(format!("ocx-posix-{}", uuid::Uuid::new_v4())) } + #[test] + fn rc_edit_is_idempotent_and_repositions_after_later_npm_prepend() { + let t = root(); record::private_dir(&t).unwrap(); + let p = t.join(".zshrc"); let b = block("zsh", &t).unwrap(); + let first = edit_rc(b"export PATH=/npm:$PATH\n", &b, false).unwrap(); + record::atomic(&p, None, &first, 0o600).unwrap(); + assert_eq!(edit_rc(&first, &b, false).unwrap(), first); + let later = [first.as_slice(), b"export PATH=/later:$PATH\n"].concat(); + let repaired = edit_rc(&later, &b, false).unwrap(); + assert!(repaired.ends_with(b.as_bytes())); + assert_eq!(edit_rc(&repaired, &b, true).unwrap(), b"export PATH=/npm:$PATH\nexport PATH=/later:$PATH\n"); + fs::remove_dir_all(t).unwrap(); + } + #[test] + fn broken_duplicate_nested_or_modified_blocks_are_refused() { + let b = block("bash", Path::new("/example/record")).unwrap(); + for bytes in [START.to_owned(), END.to_owned(), format!("{b}{b}"), + format!("{START}\n{START}\n{END}\n{END}\n"), b.replace("if [ -r", "if [ -w")] { + assert!(edit_rc(bytes.as_bytes(), &b, false).is_err()); + assert!(edit_rc(bytes.as_bytes(), &b, true).is_err()); + } + } + #[test] + fn rc_newlines_and_outside_bytes_survive_removal() { + let b = block("zsh", Path::new("/example/record")).unwrap(); + let original = b"# user's config\r\nexport PATH=\"/npm:$PATH\"\r\n"; + let installed = edit_rc(original, &b, false).unwrap(); + assert_eq!(edit_rc(&installed, &b, true).unwrap(), original); + assert!(edit_rc(b"a\r\nb\n", &b, false).is_err()); + assert!(edit_rc(&[0xff], &b, false).is_err()); + } + #[test] + fn quoting_and_invalid_path_contract_is_exact() { + assert_eq!(quote(Path::new("/example/a'b")).unwrap(), "'/example/a'\"'\"'b'"); + for s in ["relative", "/example/a:b", "/example/a\nb"] { assert!(quote(Path::new(s)).is_err()); } + } + #[test] + fn development_and_temporary_launches_are_refused_before_installation() { + assert_eq!(stable_bundle(Path::new("/example/app"), true, "1").unwrap_err(), "development-launch"); + for p in ["/Volumes/DMG/App.app/Contents/MacOS/app", "/private/var/folders/a/AppTranslocation/b/App.app/Contents/MacOS/app", "/tmp/.mount_example/usr/bin/app"] { + assert_eq!(stable_bundle(Path::new(p), false, "1").unwrap_err(), "temporary-bundle"); + } + } +} +``` + +Append to `cli_command_windows.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn prepend_preserves_raw_expansions_type_and_empty_entries() { + let raw = r"%APPDATA%\npm;;C:\Tools;"; + for ty in ["REG_SZ", "REG_EXPAND_SZ"] { + let (out, owned) = prepend(raw, r"C:\Program Files\OpenCodex", ty).unwrap(); + assert_eq!(out, format!(r"C:\Program Files\OpenCodex;{raw}")); + assert_eq!(owned.value_type, ty); assert_eq!(owned.action, "inserted"); + assert_eq!(remove(&out, &owned).unwrap(), raw); + } + } + #[test] + fn moved_existing_entry_is_restored_and_same_entry_repair_keeps_ownership() { + let raw = r"A;C:\Desktop;B"; + let (out, owned) = prepend(raw, r"c:/desktop/", "REG_SZ").unwrap(); + assert_eq!(owned.action, "moved-existing"); + let (twice, retained) = replace_owned_entry(&out, Some(&owned), r"C:\Desktop", "REG_SZ").unwrap(); + assert_eq!(twice, out); assert_eq!(retained, owned); assert_eq!(remove(&out, &owned).unwrap(), raw); + } + #[test] + fn removal_preserves_concurrent_unrelated_entries_and_ambiguity() { + let (out, owned) = prepend(r"A;B", r"C:\Desktop", "REG_EXPAND_SZ").unwrap(); + assert_eq!(remove(&format!("{out};NEW"), &owned).unwrap(), "A;B;NEW"); + let (out, moved) = prepend(r"A;C:\Desktop;B", r"C:\Desktop", "REG_SZ").unwrap(); + assert_eq!(remove(&out.replace("A;B", "A;NEW;B"), &moved).unwrap_err(), "path-restore-ambiguous"); + } + #[test] + fn replacing_owned_entry_removes_only_the_previous_insertion() { + let (out, old) = prepend("A;B", r"C:\Old", "REG_SZ").unwrap(); + let (new, owned) = replace_owned_entry(&out, Some(&old), r"C:\New", "REG_SZ").unwrap(); + assert_eq!(new, r"C:\New;A;B"); assert_eq!(remove(&new, &owned).unwrap(), "A;B"); + } + #[test] + fn duplicate_invalid_and_oversize_entries_are_refused() { + assert!(prepend(r"C:\Desktop;c:/desktop/", r"C:\Desktop", "REG_SZ").is_err()); + assert!(prepend("A", "bad;entry", "REG_SZ").is_err()); + assert!(prepend("A", r"C:\Desktop", "REG_BINARY").is_err()); + assert!(prepend(&"X".repeat(32_767), r"C:\Desktop", "REG_SZ").is_err()); + } +} +``` + +The following additional tests are implementation acceptance requirements rather than executed planning evidence. They require small fixture seams around target selection/stable-bundle observation and the orchestration action executor; the implementer must add the seams and tests in-module before marking wp1 ready. Do not claim their names alone prove the branches. Keep these seam additions within the four module budgets. + +| Guard / branch activated | Required named evidence | +|---|---| +| Fresh absent record defaults on; existing disabled record Repair/Reconcile stays off; Remove persists off before any cleanup | `fresh_stable_bundle_installs_without_npm`, `disabled_record_survives_relaunch_and_repair`, `remove_persists_disabled_intent_before_first_cleanup_write`, `disabled_pending_install_rolls_back_completed_prefix` | +| Invalid version/UUID/digest/schema/platform/section/journal nesting/path; oversized input and safe-JS generation ceiling | `record_roundtrip_and_unknown_version_refusal`, `corrupt_record_does_not_become_first_run`, `journal_cannot_address_an_arbitrary_path`, `record_validation_rejects_every_malformed_field`, `oversize_serialized_journal_is_refused_before_save` | +| Lock contention, writer crash, same-process action overlap, IO error and blocking-worker join failure | `lock_serializes_and_is_released_on_close`, `crashed_lock_owner_allows_journal_recovery`, `actions_are_serialized`, `io_failure_keeps_app_running`, `worker_failure_is_visible` | +| Missing/foreign/readonly/symlink/special target or parent, bad UTF-8/newline, duplicate/nested/incomplete/edited markers | `symlinks_special_files_and_readonly_files_are_refused`, `foreign_owner_is_refused`, `broken_duplicate_nested_or_modified_blocks_are_refused`, `rc_newlines_and_outside_bytes_survive_removal`, `missing_external_rc_parent_is_partial`, `fresh_default_fish_parents_are_created_without_chmod_existing_config` | +| Changed source between snapshot and rename, failed backup creation/fsync/rename/unlink | `atomic_write_detects_changed_original`, `backup_failure_prevents_edit`, `rename_failure_keeps_journal`, `unlink_failure_keeps_disabled_record` | +| Journal target before/after/third state; crash before/after every artifact write and final save; backup already equal/different | `pending_journal_recovers_each_prefix_and_is_idempotent`, `journal_conflict_preserves_user_edit_and_disabled_intent`, `journal_after_write_before_final_save_recovers`, `backup_conflict_is_refused` | +| Stable macOS path accepted; debug/source, AppTranslocation, /Volumes, canonical symlink-to-temporary rejected; missing/nonexecutable sibling CLI | `stable_macos_bundle_is_accepted`, `development_and_temporary_launches_are_refused_before_installation`, `canonical_temporary_bundle_is_refused`, `missing_or_nonexecutable_bundle_cli_is_refused` | +| Stable Windows sibling ocx.exe; development target/temp or sibling absent; Linux exact /usr/bin/ocx, AppImage/APPDIR/extracted layout | `windows_bundle_requires_sibling_cli`, `linux_deb_requires_installed_usr_bin_cli`, `appimage_environment_and_extracted_layout_are_refused` | +| Absolute ZDOTDIR used, relative/absent fallback; fish XDG fallback; first existing Bash login candidate including unsafe candidate; no login candidate | `zdotdir_and_xdg_selection_match_desktop_environment`, `bash_active_login_file_is_selected_without_creation`, `login_file_absent_is_partial` | +| Generated file absent/owned-identical/owned-old-version/unowned/user-modified; rc block absent/owned EOF/owned followed by npm/unowned exact | `owned_files_repair_after_bundle_move`, `owned_file_conflict_is_not_overwritten`, `generated_permissions_changed_are_refused`, `rc_edit_is_idempotent_and_repositions_after_later_npm_prepend`, `unrecorded_exact_block_is_not_adopted` | +| Remove absent block/file; only whitespace in installer-created rc; other content in preexisting/created rc; modified rc keeps helper and ownership | `remove_deletes_only_owned_artifacts`, `remove_retains_nonempty_created_rc`, `modified_rc_keeps_helper_until_cleanup_is_safe` | +| Proof utilities/urandom available, missing od/awk/urandom, short/error/malformed output, empty exports and duplicate proof argv | TS `shim proof is accepted by the actual initializer and captures only nonempty exports`, `missing od falls back without inherited proof or context`, `short random read falls back without context`; add `missing_awk_or_urandom_falls_back`, `duplicate_internal_proofs_are_untrusted`; packaged `ocx claude` acceptance in 030 | +| PATH unset/empty/empty interior elements/existing Desktop duplicates | `path_helper_preserves_empty_entries_and_prepends_once` (run `/bin/sh` against generated helper and a supplied child env) | +| Windows insert/move/same repair/replacement; missing owned entry; adjacent anchors removed or ambiguous; duplicates/oversize/type errors | `prepend_preserves_raw_expansions_type_and_empty_entries`, `moved_existing_entry_is_restored_and_same_entry_repair_keeps_ownership`, `removal_preserves_concurrent_unrelated_entries_and_ambiguity`, `replacing_owned_entry_removes_only_the_previous_insertion`, `duplicate_invalid_and_oversize_entries_are_refused`, `missing_owned_path_is_idempotent` | +| Raw registry value missing/REG_SZ/REG_EXPAND_SZ/bad UTF-16/unsupported/type changed/concurrent raw edit, read/write denial | Windows cfg tests `registry_roundtrip_preserves_type_and_unexpanded_text`, `registry_type_change_blocks_recovery`, `registry_denial_keeps_disabled_intent`; no Ubuntu/native claim | +| Machine PATH existing CLI / clean / unresolved expansion / read denial; broadcast success/timeout | Injected `machine_path_conflict_is_partial`, `machine_path_unobserved_is_partial`, `broadcast_failure_is_partial`; real Windows evidence in 030 | +| Local page main valid spelling; wrong label/origin/scheme/path/query/fragment/unreadable URL; tray/window/navigation failure | `cli_page_is_exact_local_origin_and_path`, source `registers four path-free commands and guards every wrapper`; native `wrong_label_and_unreadable_url_are_refused`, `tray_navigation_failure_is_visible` | +| CLI page during Launch/Recover; user explicitly Back; UI absent bridge/unknown phase/error/timeout/busy/poll overlap | source `ready completion preserves CLI settings during launch and recovery`, `missing Tauri disables controls and states the entry point`, `UI never sends an arbitrary path and only toggles a boolean`; VM `unknown_status_and_timeout_are_visible`, `overlapping_clicks_send_one_write` | + +These test additions are essential remaining implementation work, not a request to run a suite in this planning worker. Parent should require the names to exist and assertion activation to be reviewed, rather than treating this table as test evidence. + +## MODIFY owning and public documentation + +Append the following section immediately before `## Release packaging and updater` (`structure/desktop-shell.md:418`). This is a present-tense contract accompanying the implemented behavior, not an implementation diary. It adds 26 lines, leaving 28 of the 600-line budget at the fixed base. The manifest already maps the desktop source area; review fan-out includes existing runtime ownership prose, but this record creates no runtime authority and needs no duplicate statement there. + +```diff +@@ before Release packaging and updater ++## Desktop-owned terminal command ++ ++`desktop/src-tauri/src/cli_command.rs` schedules one blocking reconcile after launch-origin ++adoption, independently of proxy startup. A stable release install configures the terminal ++command by default; a disabled choice survives launch and removal. AppImage, temporary, ++translocated, debug and unpackaged launches do not install it. ++ ++`desktop/src-tauri/src/cli_command_record.rs` owns the single private user-home ++`.opencodex-desktop/cli.json` record, OS-backed lock and pending journal. The record describes ++generated-file ownership only; it grants no runtime, service or shutdown authority. Invalid ++records block automatic mutation. Pending changes recover only from recorded before/after bytes. ++ ++`desktop/src-tauri/src/cli_command_posix.rs` generates a fail-closed shim, shared PATH helper ++and final managed zsh/bash/fish blocks. It refuses unsafe files and changed/unowned blocks. ++The shim captures nonempty exported Anthropic slot names with an argv-bound random proof ++before dotenv; inability to generate the proof retains compiled-CLI stripping behavior. ++ ++`desktop/src-tauri/src/cli_command_windows.rs` preserves raw HKCU Path text/type, changes only ++the owned entry and broadcasts Environment. Machine-Path conflicts are partial; already-open ++shells are unobserved. Windows direct execution retains current Anthropic stripping behavior. ++ ++The bundled `desktop/ui/cli.html` page uses four path-free commands guarded by the main ++window's exact local page URL. The tray's Terminal command item opens it. Removal stores off ++intent before cleanup and preserves modified user files. Configuration is not a guarantee ++against aliases, absolute commands, shell caches, skipped startup files or later PATH changes. ++ + ## Release packaging and updater +``` + +English exact text, inserted before `## Updates` (`docs-site/src/content/docs/guides/desktop-app.md:169`): + +```diff +@@ before Updates ++## Terminal command ++ ++On a stable macOS app, Windows installation, or installed Linux deb, Desktop automatically ++configures its bundled `ocx` for new terminals at launch. Choose **Terminal command…** in ++the tray menu to turn this off, repair it, or remove it. The off choice survives restarts. ++AppImage and development launches do not configure the terminal command. ++ ++macOS and Linux use a Desktop-owned shim and managed zsh, bash and fish startup blocks. ++Windows prepends the installation directory to the user `Path`; a command in the system ++`Path` can still win. Open a new terminal and check `type -a ocx` on POSIX, or ++`Get-Command ocx -All` and `where.exe ocx` on Windows. Existing shells, aliases and later ++PATH changes can still select another command. A missing bundle makes the POSIX shim fail ++with repair/removal guidance instead of silently running an npm copy. ++ ++POSIX preserves shell-exported Anthropic settings when launch-proof utilities are available. ++If proof generation is unavailable, and for Windows direct execution, the bundled CLI keeps ++its existing removal of untrusted Anthropic environment settings. Installer errors or ++system-Path conflicts are shown as partial or blocked configuration, not verified selection. ++ + ## Updates +@@ Uninstall, before On macOS ++Before uninstalling a supported Desktop installation, choose **Terminal command… → Remove ++terminal command**. Desktop removes only its unchanged managed configuration. Modified ++blocks or files are preserved and reported; resolve them before deleting the app. Removing ++the app directly does not guarantee cleanup of shell files or the user `Path`. ++ + On macOS, drag `OpenCodex.app` from Applications to the Trash. On Windows, remove +``` + +Korean exact text, inserted before `## 업데이트` (`docs-site/src/content/docs/ko/guides/desktop-app.md:61`): + +```diff +@@ before 업데이트 ++## 터미널 명령 ++ ++안정된 위치의 macOS 앱, Windows 설치본, 설치된 Linux deb에서는 Desktop이 시작할 때 새 터미널이 번들 `ocx`를 사용하도록 자동 설정합니다. 트레이의 **Terminal command…**에서 끄기, 복구, 제거를 선택할 수 있습니다. 꺼 둔 선택은 재시작 후에도 유지됩니다. AppImage와 개발 실행에서는 터미널 명령을 설정하지 않습니다. ++ ++macOS와 Linux에서는 Desktop 소유 shim과 zsh, bash, fish 시작 파일의 관리 블록을 사용합니다. Windows에서는 설치 디렉터리를 사용자 `Path` 앞에 추가하며, 시스템 `Path`의 다른 명령이 먼저 선택될 수 있습니다. 새 터미널에서 POSIX는 `type -a ocx`, Windows는 `Get-Command ocx -All`과 `where.exe ocx`로 확인하세요. 이미 열려 있던 셸, 별칭, 이후 PATH 변경은 다른 명령을 선택할 수 있습니다. 번들이 사라지면 POSIX shim은 npm 복사본으로 조용히 전환하지 않고 복구 또는 제거 안내와 함께 실패합니다. ++ ++POSIX에서는 launch proof 생성 도구가 있으면 셸에서 export한 Anthropic 설정을 보존합니다. proof를 생성할 수 없거나 Windows에서 직접 실행하는 경우에는 신뢰되지 않은 Anthropic 환경 설정을 제거하는 기존 번들 CLI 동작을 유지합니다. 설치 오류나 시스템 Path 충돌은 선택 성공으로 표시하지 않고 부분 적용 또는 차단으로 표시합니다. ++ + ## 업데이트 +@@ 제거, before macOS에서는 ++지원되는 Desktop 설치본을 제거하기 전에 **Terminal command… → Remove terminal command**를 선택하세요. Desktop은 변경되지 않은 자신의 관리 설정만 제거합니다. 사용자가 수정한 블록이나 파일은 보존하고 알려 주므로, 앱을 삭제하기 전에 처리하세요. 앱만 직접 제거하면 셸 파일이나 사용자 `Path` 정리가 보장되지 않습니다. ++ + macOS에서는 Applications의 `OpenCodex.app`을 휴지통으로 옮깁니다. Windows에서는 **Installed apps**에서 OpenCodex를 제거합니다. Debian 기반 Linux에서는 다음 명령을 실행합니다. +``` + +A read-only `rg -n -i` pass over all eight guide files exited 0 and exposed existing removal-first instructions in ja/fr/tr/ru/zh-cn/zh-tw as well as en/ko. It found no new Desktop PATH guarantees in the translated guides. This is discovery evidence, not semantic sign-off. Other locale review must inspect **all eight** desktop guide copies, not GUI translation catalogs. Search installer, first-run, terminal-command, PATH and uninstall sections; compare claims to English for default-on/off persistence, AppImage exclusion, no absolute guarantee, Windows machine/user Path and cleanup-before-delete. Existing locale pages primarily omit PATH behavior; omission alone is not a contradictory guarantee, but their old uninstall-only instructions are incomplete after this change. 030 owns translated follow-ups if needed. Do not mark locale consistency complete until a human reads the matches and records the affected locales; string matching/build success alone cannot establish semantic agreement. + +## Guard layers, bypasses and residual risks (PLAN-BYPASS-NAMED-01) + +This plan is E7 agent-followed direction: no CXC hook implements it. The arrow names the concrete proposed product/OS/CI executing surface after implementation, without inventing CXC hook tiers for ordinary Rust code. Wording is downgraded in **every row** to a configuration/ownership guard or early warning rather than universal command-selection enforcement. Final unbypassable command-selection layer: **none**. + +| Mechanism | Tier / execution location | Known bypass | Residual risk / honest claim | +|---|---|---|---| +| Stable-bundle gate | E7 plan directive → packaged-app Rust guard | Same-user modifying/copying a release executable; Windows sibling layout is not signed-installer attestation | Prevents routine debug/temporary installs; not binary authenticity. Linux exact layout excludes extracted bundles but is not a dpkg signature check. | +| Record validation + permissions + OS lock | E7 plan directive → record writer / kernel lock | Same-user noncooperating edits, profile ACL changes | Cooperating Desktop writers serialize; hashes detect changed content, not hostile same-user control. Windows private-profile ACL is inherited. | +| Journal before mutation + atomic file rename | E7 plan directive → artifact writer | Noncooperating editor in last check-to-rename interval; power loss / registry lacks atomic rename | Conflict blocks and leaves journal; no claim of multi-file atomicity. Backups and before bytes allow inspection/recovery. | +| Exact generated-file/block ownership | E7 plan directive → install/remove | User replaces metadata and files together; rc returns early or stale .zwc compiled file bypasses text | Refuse unowned/changed files; preserve outside bytes. EOF position proves text placement, not executed shell control flow. | +| Shell PATH block/helper | E7 plan directive → new-shell initialization | `zsh -f`, startup early return, aliases/functions, hashed commands, later `nvm use`, explicit npm path, hidden .zshenv ZDOTDIR | Supported new-shell configuration only; parent-shell actual selection is unobserved by Rust. | +| HKCU Path prepend + broadcast | E7 plan directive → Windows environment construction | Machine Path, cwd lookup, app execution aliases, old Terminal parent env, PATH rewrite | Conflicts are partial; restart terminal and inspect native selection. Broadcast does not refresh existing cmd/PowerShell. | +| Fail-closed POSIX shim | E7 plan directive → terminal shim invocation | Explicit unrelated executable, replaced same-user shim, another command before shim | Missing/nonexecutable bundle fails 127; exec failure remains an error, no npm search. | +| argv-bound random launch proof | E7 plan directive → pre-dotenv shim / initializer | Same-user process able to control argv/env; missing utilities; duplicate proof args | Preserves normal shell export provenance; not OS authentication. Fallback keeps existing stripping. Independent security review required. | +| Exact local-page command guard | E7 plan directive → each native wrapper before IO | Determined same-user process acting outside Tauri | Blocks ordinary remote webview/main-page IPC misuse; UI cannot select arbitrary targets. Not an OS-user boundary. | +| File-size / structure / test-layout gates | E7 execution directive → repository static CI | Unregistered NEW file outside index; Rust isn't ratchet-scanned; declarations with weak assertions | Stage only implementation files when authorized; inspect tests, keep explicit Rust budgets. Passing gates do not prove native behavior. | +| Independent security/code review | E7 parent-owned 030 review | Reviewer blind spots / missing native evidence | Requires explicit verdicts and known coverage limits; no review claimed by this plan. | + +## File budgets and ratchets + +`THRESHOLD=2000` at `scripts/file-size-ratchet.ts:4`; `.rs` and `.html` are absent from SCAN_EXTENSIONS at :7, `.js/.ts/.json/.md` are scanned. `devlog/` is excluded at :22, so this detailed plan is not a product-file ratchet exception. `tests/fixtures/file-size-baseline.json:18` starts the cap map; none of this phase's existing scanned targets has an individual ratchet cap. Do not add or raise caps. `structure/manifest.json:3` sets **600 lines**, independently of the generic 2000-line guard. Do not edit manifest budget or baseline to accommodate growth. + +| File(s) | Base lines | Limit / proposed budget | Decision | +|---|---:|---:|---| +| `structure/desktop-shell.md` | 546 | 600 / +26 = 572 | 28 lines remain for concurrent L1 changes; remeasure after rebase and compress/split if needed. | +| `desktop/src-tauri/src/startup.rs` | 2609 | .rs not scanned / +4 | Thin scheduling + page guard only; do not grow orchestration here. | +| `desktop/src-tauri/src/lib.rs` | 388 | .rs not scanned / <=465 | Wrappers/registration/state only. | +| `desktop/src-tauri/src/window.rs` | 357 | .rs not scanned / <=415 | URL guard + tests. | +| `desktop/src-tauri/src/tray.rs` | 637 | .rs not scanned / <=645 | One item and dispatch arm. | +| NEW `cli_command.rs` | 0 | team budget <=220 | Includes orchestration regression seams/tests. | +| NEW `cli_command_record.rs` | 0 | team budget <=600 | Schema/lock/IO/recovery and failure tests. | +| NEW `cli_command_posix.rs` | 0 | team budget <=600 | Exact scripts + pure edits + filesystem tests. | +| NEW `cli_command_windows.rs` | 0 | team budget <=420 | Pure transforms + minimal FFI + portable/native tests. | +| NEW `cli.html` / `cli.js` | 0 | HTML manual <=90 / JS ratchet 2000, plan <=160 | Native local page only. | +| NEW source-contract TS test | 0 | 2000 / <=230 | Dedicated sibling test; don't enlarge desktop-update-surface.test.ts. | +| `scripts/test-layout/layout.json` / `tests/fixtures/test-layout-expected.json` | 1981 / 1179 | 2000 / +1 each = 1982 / 1180 | Both exact registrations required. | +| desktop guide en/ko | 207 / 85 | 2000 / +24 en, +10 ko | Check localized copies semantically. | + +Rust budgets are planning caps, not existing mechanical enforcement. Future growth beyond one is a plan amendment, not justification to loosen the existing repository ratchet. + +## Verifier receipts (PLAN-VERIFIER-REAL-01) + +All shell commands used the delegated repository root as workdir. No product code was created and no test suite was run. Commands below are distinguished from implemented-feature evidence. Validation commands read the current checkout, so a 0 here proves baseline compatibility only. Run-once discovery help commands do **not** claim to read new Rust files. + +| Exact command / observation | Exit | Does it read this change's targets? | +|---|---:|---| +| In-memory NEW Rust block extraction → `rustfmt --edition 2021 --emit stdout` on stdin, for each of four files plus its supplied cfg tests | 0 each | Reads the code printed in this NEW plan. Syntax parsing only, not symbol/type/Clippy/test validation; does not write product files. | +| In-memory `cli.js` block extraction → `node --check` on stdin | 0 | Reads the exact NEW JS code in this plan; syntax only, does not execute UI. | +| `cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --check` | 0 | Reads current Rust module graph; NEW Rust modules only after creation and mod registration. | +| `cargo clippy --help` | 0 | No; command interface discovery only. | +| `cargo test --help` | 0 | No; command interface discovery only. | +| `bun run typecheck` | 1 | Invoked current tsconfig (`tsconfig.json:15`, `include: ["src"]`), failed TS2688 missing `bun-types`. This command does not observe wp1 Rust/UI/test changes; it is a parent integration prerequisite, not this phase's verifier. | +| `bun run structure:check` | 0 | Reads current owning doc/manifest/index (`scripts/structure-ssot.ts:294`, runStructureChecks; `:352`, document reads); NEW paths require implementation staging because this gate uses the index. Does not inspect this devlog plan. | +| `bun run privacy:scan` | 0 | Reads tracked/source tree according to scanner, includes only indexed unit documents (`scripts/privacy-scan.ts:60`, gitLsFiles; `:410`, runScan). This NEW untracked plan was not observed; later staging/scanning must include it and implementation. No new-feature correctness proof. | +| `wc -l scripts/test-layout/layout.json tests/fixtures/test-layout-expected.json docs-site/src/content/docs/guides/desktop-app.md docs-site/src/content/docs/ko/guides/desktop-app.md` | 0 | Reads exact targeted JSON/doc line counts: 1981/1179/207/85. | +| `bun scripts/file-size-ratchet.ts` | 0 | Scans indexed current eligible files/baseline (`scripts/file-size-ratchet.ts:134`, scanRepo/git ls-files); .rs/.html and devlog excluded. NEW JS/TS must be staged before meaningful final proof. | + +Implementation commands that depend on missing NEW files are labeled **NEW 파일 생성 후 실행** (“run after NEW file creation”). They were not run as suites in this planning worker. Do not attach invented exit codes or treat the command's help receipt as the following command's result. + +| Command | Execution status / change-target coverage | +|---|---| +| `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings` | NEW 파일 생성 후 실행; not run (would compile/write artifacts outside the one-document scope). Reads registered modules and cfg test targets. | +| `cargo test --manifest-path desktop/src-tauri/Cargo.toml cli_command -- --test-threads=1` | NEW 파일 생성 후 실행; not run (minimal-test plan-only boundary). Reads all four modules' named tests; window URL test is separately needed. | +| `cargo test --manifest-path desktop/src-tauri/Cargo.toml cli_page_is_exact_local_origin_and_path` | NEW 파일 생성 후 실행; not run; directly activates the local-origin predicate. | +| `bun test tests/clients/desktop-cli-command-surface.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | NEW 파일 생성 후 실행; not run; reads Rust/UI source as data and both layout JSON files. No import-graph coverage assumption. | +| `bun test tests/clients/desktop-update-surface.test.ts tests/clients/desktop-startup-surface.test.ts` | NEW 파일 생성 후 실행; not run per no-extra-suites instruction; later regression checks preserve update and startup source contracts. | +| `bun run typecheck` after dependencies are available | NEW 파일 생성 후 실행; baseline failure above must be resolved by parent without claiming it passed. | +| `bun run structure:check` / `bun run privacy:scan` / `bun scripts/file-size-ratchet.ts` | Actual baseline receipts above; repeat after new code/docs to read changed targets. Passing unchanged checks need no additional confidence reruns. | +| `bun --cwd docs-site install --frozen-lockfile` then `bun --cwd docs-site run build` | NEW 파일 생성 후 실행; not run: install/build writes dependencies/output outside delegated file scope. Required by docs-site/AGENTS.md at implementation; no documentation build claim. | +| packaged fresh POSIX shell / native Windows Path validation | Not run; 030 owns temporary-profile evidence, true platform selection and packaged `ocx claude`. Do not mutate a developer's own rc/registry. | + +The user specifically constrained this worker to a single document and minimal local tests. That bound overrides a skill's general desire to execute implementation verifiers. Baseline fmt/structure/privacy/ratchet and command help were inexpensive read-oriented discovery; compiling a nonexistent implementation or running suites would provide no new-feature evidence. + +## Handoff risks and required parent decisions + +1. **No-tray Linux entry:** The accepted A6 tray entry cannot be reached on a Linux session with no tray host. The page can be served independently of the proxy, but no new bootstrap/dashboard entry was authorized in this slice. Parent should approve a fixed `cli.html` link in `desktop/ui/index.html` or another local entry and add that precise diff before wp1 implementation is accepted. This is a reported UI scope expansion, not an implemented file change. +2. **Platform proof:** Ubuntu Rust tests cover Path strings, not raw registry IO/WM_SETTINGCHANGE or cmd/PowerShell selection. macOS/Linux packaged CLI provenance must be checked with `ocx claude`; Windows explicitly retains existing stripping. Missing external ZDOTDIR/XDG parents and unsafe rc files are partial outcomes requiring user repair. +3. **Concurrent L1/doc edits:** Owning doc headroom is 28 lines after this proposal; L1 also changes this doc and the guides. Preserve the runtime-authority separation and remeasure against the integrated head. Source anchors are pinned to 68c9d35457 even though origin/dev has moved. +4. **Schema coordination:** 020 must use these exact field names, nullability and pending semantics. A public record is ownership metadata only. A pending install disabled before recovery rolls back only its completed prefix as defined by `Store::recover`; no launcher should select a pending target. +5. **Verification boundary:** Typecheck failed because bun-types is unavailable. No dependency installation, test suite, native behavior or independent review is claimed. Security analysis beyond these public design controls stays in scratch, under repository AGENTS.md. + +Final plan self-check: all four complete NEW Rust production blocks and supplied test blocks parsed through rustfmt stdin with exit 0; complete NEW cli.js parsed through node --check stdin with exit 0. Markdown fences were balanced and the document contained zero personal-machine path occurrences. These checks inspect proposed text only. No production edit, test suite, branch operation, or native environment mutation was performed. diff --git a/devlog/_fin/261009_desktop_owned_path_cli/011_wp1_execution.md b/devlog/_fin/261009_desktop_owned_path_cli/011_wp1_execution.md new file mode 100644 index 00000000000..455f2cf76b9 --- /dev/null +++ b/devlog/_fin/261009_desktop_owned_path_cli/011_wp1_execution.md @@ -0,0 +1,72 @@ +# 011 — wp1 execution plan (re-verified) + +wp1 implements 010 as amended by 003 (AM-5..AM-13; AM-3 withdrawn by AM-11). This document is the cycle's executable plan: what was re-verified against the current base, the consolidated worklist, the worker split for B, and the C checks. Where 010 and this document differ, this document and 003 win. + +## Stale check against the new base + +Base: `origin/dev` 71c4ae3c8e (after #6802, #6805 and today's other merges); branch `codex/desktop-owned-path-cli` rebased, roadmap commit 8a1ea2c070. + +| 010 anchor | Current state | Action | +|---|---|---| +| `startup.rs` `register` (1449), `adopt_launch_origin_argument(app)` (1473), `finish` (1589), `keeps_update_page` (1688) | Unchanged | Apply as written | +| `lib.rs` update commands guarded by `require_update_page` (221–241), `return_to_dashboard` (249–250), `generate_handler!` (287) | Unchanged | AM-12 widens only line 250 | +| `window.rs` `is_app_origin` (105), `require_update_page` (113) | Unchanged | Add `require_cli_page` and `require_local_settings_page` | +| `tray.rs` `install` (72), `check-updates` item (91) and handler (219) | Unchanged | Add the terminal-command item next to it | +| `structure/desktop-shell.md` | 568 lines (was 546; #6802 added 22) | Budget +26 → 594 ≤ 600; keep the addition at or under 30 lines | +| desktop guide en/ko | #6802 added "Using the ocx CLI with the desktop app" | Extend that section with the terminal-command paragraphs instead of a new competing section | +| `scripts/test-layout/layout.json` 1981 / `tests/fixtures/test-layout-expected.json` 1181 | Unchanged in shape | Register the new test in both | +| Toolchain | cargo/rustc 1.95.0 present locally | `cargo fmt`/`clippy`/`test` run locally on macOS | + +## Consolidated wp1 worklist + +1. Rust record module (`cli_command_record.rs`): schema per 010 with AM-1/AM-6 external journal, AM-5 validation and limits (`rcFiles ≤ 16`, `changes ≤ 32`, record ≤ 64 KiB on write and read), AM-7 ownership list validation, AM-10 `Record::fresh(bundle)`, lock. +2. Rust POSIX module (`cli_command_posix.rs`): stable bundle (AM-10 order), shim with A11 proof generation, `path.sh`, zsh/bash/fish blocks, rc edit/reposition/removal with AM-7 desired-vs-owned reconciliation. +3. Rust Windows module (`cli_command_windows.rs`): pure Path transforms + raw HKCU IO + `WM_SETTINGCHANGE` (FFI as in 010, no new crate). +4. Rust orchestration (`cli_command.rs`): reconcile on launch (blocking worker after `adopt_launch_origin_argument`), `status`, `set_enabled`, `install`, `remove` (disabled intent stored first), AM-6 recovery state table. +5. Wiring: `lib.rs` four commands + `return_to_dashboard` guard (AM-12); `window.rs` guards; `startup.rs` hook + keep `cli.html` on Ready like the update page; `tray.rs` item. +6. Local page: `desktop/ui/cli.html` + `cli.js` (Back → `return_to_dashboard`). +7. Dashboard entry (AM-11): `gui/src/lib/desktop-shell.ts` `desktopCliPageUrl`/`openDesktopCliPage`, desktop-only entry beside the Desktop update row, strings in every GUI locale, GUI unit test. +8. Shared fixtures `tests/fixtures/desktop-cli-record/*.json` (AM-5), used by Rust tests via `include_str!` now and by wp2's TS reader later. +9. Source-contract test `tests/clients/desktop-cli-command-surface.test.ts` + both layout registrations. +10. Docs: `structure/desktop-shell.md` (≤ 30 lines added), desktop guide en/ko extension of the existing CLI section, locale contradiction sweep. + +## B worker split (disjoint files, one worktree, no branch operations) + +| Worker | Files | Depends on | +|---|---|---| +| W1 Rust core | items 1–4, 8 | 010 interfaces | +| W2 Wiring and local page | items 5, 6, 9 | W1's public function names as fixed in 010 (`reconcile_on_launch`, `status`, `set_enabled`, `install`, `remove`, `State`) | +| W3 Dashboard and docs | items 7, 10 | none | + +Main integrates, runs the C checks and fixes compile errors across the seams. + +## C checks for wp1 + +| Command | Reads the change? | +|---|---| +| `cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --check` | Yes, once the modules are declared in `lib.rs` | +| `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings` | Yes (compiles all targets including tests) | +| `cargo test --manifest-path desktop/src-tauri/Cargo.toml cli_command` and `... window` | Yes, the new in-module tests and the URL guards | +| `bun test tests/clients/desktop-cli-command-surface.test.ts tests/clients/desktop-update-surface.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | Yes; reads Rust/UI source as data and both layout files | +| `cd gui && bun test && bun run lint && bun x tsc -b` | Yes for the dashboard entry | +| `bun run structure:check`, `bun run privacy:scan`, `bun scripts/file-size-ratchet.ts` | Yes after the files are staged | +| Real-shell check (030, c-1/c-2) with a temporary `HOME` on macOS zsh and bash | Yes; runs the Rust reconcile through a test binary and then real shells | +| A11 compiled check (AM-13) | Moved to wp3 C, where the compiled host CLI is built once for both PRs | + +Everything else (Windows native, fish, Linux deb, packaged app click-through) is release QA, named in PR 1. + + +## Reflection (same architect, 01a11f35): ALIGNED + +Folded into this plan: + +- W2 also depends on W1's `show_page` (the tray handler calls it). +- The surface test's dashboard-gating assertion depends on W3's output, so main runs it after integrating W1–W3. +- C adds a rendered check of `cli.html` with a stubbed `invoke` (static render in a headless browser, separate from the packaged click-through QA), `cd gui && bun run build` (Vite build, not only `tsc -b`), the GUI test file `gui/tests/desktop-shell.test.ts`, and the PR screenshot of the dashboard entry. +- At wp1 close, A11 is recorded as "implemented, verification pending in wp3" (shim run + control run + independent security review; AM-9 merge decision if the compiled check cannot run). + + +## Audit (independent, 01a11f6b): NEAR-PASS + +Folded: C adds `cd gui && bun run lint:i18n` and `cd gui && bun test tests` (callback wiring and conditional render); the rendered `cli.html` check reads its screenshot and exercises toggle, remove and Back against the stubbed `invoke`, recorded separately from any real Desktop round trip; Windows-only code (registry IO, FFI) is neither compiled nor run on macOS `clippy --all-targets` and is named as residual (compile coverage only if a Windows CI job builds the desktop crate; native behavior is release QA). + diff --git a/devlog/_fin/261009_desktop_owned_path_cli/019_wp1_done.md b/devlog/_fin/261009_desktop_owned_path_cli/019_wp1_done.md new file mode 100644 index 00000000000..c3f43022473 --- /dev/null +++ b/devlog/_fin/261009_desktop_owned_path_cli/019_wp1_done.md @@ -0,0 +1,29 @@ +# 019 — wp1 outcome + +wp1 is implemented on `codex/desktop-owned-path-cli` (commits ab5a658b32 → 9a1da663a0, based on `origin/dev` 71c4ae3c8e). Workers: W1 Rust core (`01a11f84-f4e9`), W2 wiring and local page (`01a11f84-f5a1`), W3 dashboard and docs (`01a11f84-f66b`), real-shell test (`01a11fa3-3e5b`), all gpt-6.1-sol; main integrated and fixed the Windows findings. + +## What exists now + +- `desktop/src-tauri/src/cli_command.rs`, `cli_command_record.rs`, `cli_command_posix.rs`, `cli_command_windows.rs`: record and external journal (AM-1/AM-6), shared validation and limits (AM-5), owned-vs-desired rc reconciliation (AM-7), first record carries its bundle (AM-10), shim with launch provenance (A11), `path.sh`, zsh/bash/fish blocks, Windows user `Path` transforms and raw registry IO. +- Wiring: four commands behind `require_cli_page`; `return_to_dashboard` behind `require_local_settings_page` (update and terminal-command pages only; the update commands keep `require_update_page`); reconcile scheduled after `adopt_launch_origin_argument`; tray item; `desktop/ui/cli.html` + `cli.js`. +- Dashboard entry (AM-11): `desktopCliPageUrl`/`openDesktopCliPage`, desktop-only "Terminal command" entry, strings in all 11 GUI locales. +- Shared record fixtures `tests/fixtures/desktop-cli-record/` (10 files), source-contract test `tests/clients/desktop-cli-command-surface.test.ts`, docs (`structure/desktop-shell.md` 597/600, desktop guide en/ko extending the existing CLI section). + +## Evidence + +| Check | Result | Observes | +|---|---|---| +| `cargo fmt --check`, `cargo clippy --all-targets -D warnings`, `cargo test` (macOS, Rust 1.95) | 0 / 0 / 228 passed, 2 ignored | All new modules, guards and tests compiled for macOS | +| Same on Windows 11 host `mini` (Rust 1.98), `cargo clippy --all-targets -D warnings` + `cargo test cli_command` | 0 / 14 passed | Windows-only registry IO, FFI and Path transforms compile and pass; it first found two lints and a JSON escaping bug in a test, fixed in d669955 and 116985a | +| `cargo test real_shell -- --ignored` (macOS) | 1 passed | Real `zsh -i` and `bash -l -i` on a temporary HOME with an npm-style `ocx` prepended in `.zshrc`/`.bashrc`: the Desktop shim wins; an npm prepend appended after the block loses again after reconcile moves the block back to the end; Remove restores the npm `ocx` and keeps the user's lines (c-1/c-2 on macOS) | +| `bun test` surface + update/startup surface + layout guards | 59 pass, 0 fail | Command registration, guards, page invocation contract, layout registration | +| GUI: `lint`, `lint:i18n`, `tsc -b`, `bun test tests`, `build` | 0, 0, 0, 3018 pass, 0 | Dashboard entry, helpers, locales | +| `bun run typecheck`, `structure:check`, `privacy:scan`, file-size ratchet | all pass | Repository gates | +| Rendered `cli.html` with stubbed `invoke` (headless Chrome via agbrowse, isolated profile) | Initial render correct; Repair → `cli_install` and partial state with issue list; Remove → `cli_remove` and "off"; toggle → `cli_set_enabled`; Back → `return_to_dashboard` (never `show_dashboard`) | Page behavior; not a packaged Desktop round trip | + +## Open for wp3 and release QA + +- A11: implemented; compiled-CLI verification (shim run + control run, AM-13) and the independent security review are pending in wp3. +- Not observed by this phase: packaged OpenCodex.app click-through (dashboard → Terminal command → Back), fish, Linux deb, Windows native terminal selection after a real registry write. Release QA items for PR 1. +- Planning line budgets for the new Rust modules were exceeded (`cli_command_record.rs` 1197, `cli_command_posix.rs` ~1000, `cli_command_windows.rs` 476, `cli_command.rs` 439). Rust is not under the repository ratchet; the budgets were planning caps. Accepted as an amendment; reviewers may ask for a split. + diff --git a/devlog/_fin/261009_desktop_owned_path_cli/020_launcher_handoff_and_diagnostics.md b/devlog/_fin/261009_desktop_owned_path_cli/020_launcher_handoff_and_diagnostics.md new file mode 100644 index 00000000000..7c5254d9e72 --- /dev/null +++ b/devlog/_fin/261009_desktop_owned_path_cli/020_launcher_handoff_and_diagnostics.md @@ -0,0 +1,1421 @@ +# 020 — Launcher handoff and command-selection diagnostics (wp2) + +Desktop owns the terminal `ocx` command through wp1's PATH installation. This work makes a current package launcher delegate to the recorded Desktop CLI before it repairs its package or searches for Bun, so older terminal sessions can use the bundled CLI too. It preserves package update/removal and inspection behavior, regenerates the pre-dotenv launch proof, and adds read-only PATH observations to status and doctor. Diagnostics distinguish configured Desktop intent, the first executable on the current process PATH, and parent-shell selection that this process cannot observe. + +## Authority, execution boundary and dependency + +| Item | Contract | +| --- | --- | +| Archetype / trigger | Bounded satisfy-spec integration work, delegated from 000 wp2 after the Desktop/PATH audit. Parent owns FSM, goals, loop, consultation and review. | +| Goal | Implement A7, A8 and the TS portion of A10 as disposed in 002; Dispositions override the architect proposal. | +| Prerequisite | wp1/010 creates the authoritative record and owned shim. The minimal reader contract below must be reconciled with 010 before implementation. | +| Stop | This planning worker returns this one document; no code edits, git branch mutations, commits, child dispatch or goal/FSM operations. | +| Verifiers | Actual planning-time results and target coverage are recorded below. Newly proposed tests run only after creation. | +| Artifact | This 020 document; 001 is investigation evidence, 002 is decision authority, 010 owns the writer, 030 owns delivery. | +| Outcomes | Plan ready for parent's audit; implementation remains unverified. Contract mismatch or security/provenance concern goes to parent. | +| Escalation | A4 schema change; L1 rebase changes a shared contract; A11 review failure; Windows native selection conflict. | +| Resource boundary | Local validation stays minimal. No full test suite in this planning task. No user-machine installation or PATH writes. | + +All source line references below describe **68c9d354574ed240c17805493409f8c589682b91**, the audit's origin/dev snapshot. Local HEAD is that commit; the moving origin/dev ref was already `8ad91f4c03` when checked. Resolve implementation by function anchors and rebase, never by these line numbers alone. + +| Scope | IN | OUT | +| --- | --- | --- | +| A7 | Fixed-record reader; validated target; async handoff; regenerated proof; command exceptions; probe bypass | Record writes, PATH/rc/registry writes, bundle discovery, resource copying, service/runtime authority | +| A8 | PATH/PATHEXT candidate observation; status `cliCommand`; one human line; doctor section | Executing candidates, aliases/functions, shell cache introspection, changing Codex runtime selection | +| A10 TS | Two CLI test files and both layout registrations; affected owning-doc diffs and public text | Rust/UI tests (010), CI/release changes, approval/merge (030) | +| Shared source | Thin hooks in launcher, status, doctor and managing-cli | `src/cli/index.ts` (1976 lines), other worker's plan files | + +## Source evidence and exact file map + +| Baseline evidence | Function / anchor | Consequence | +| --- | --- | --- | +| `bin/ocx.mjs:951–963` | `updateHelpRequested`, `codexCliUpdateInspection` | Insert only after both existing classification blocks. | +| `bin/ocx.mjs:964–996` | mise update, `runNpmSelfUpdate`, `bootRestoreProbe`, `resolveBun` | Handoff precedes these operations; exceptions still reach them. | +| `bin/ocx.mjs:1018–1074` | pre-Bun slot snapshot and launch-context generation | Wrap the existing body in one factory, reuse for both launch paths; do not trust inherited context. | +| `bin/ocx.mjs:1075–1120` | async child, `FORWARDED`, `clearHandlers`, error/exit listeners | Reproduce asynchronous signal forwarding and exit mirroring for Desktop. Keep the Bun child path intact. | +| `src/cli/launcher-context.ts:54–123` | `initializeNodeLauncherContext` | Consumer requires exactly one argv proof and matching version-1 JSON, removes the proof and environment context. | +| `src/cli/claude.ts:65–77,630` | `deleteUntrustedAnthropicSlots`, `buildNativeClaudeEnv` | The handoff test must reach the real Claude consumer, not merely assert a JSON key. | +| `src/service/managing-cli.ts:62–85` | `probeVersionOnce` | Probe an actual selected package executable by suppressing handoff in its child environment. | +| `src/cli/status.ts:107,220,682,870–937` | `CliStatusJson`, `CliStatusView`, `collectStatus` | Add separate command diagnostics, never repurpose `paths.runtime` or `codexRuntime`. | +| `src/cli/index.ts:1693–1730` | `handleStatus` | Existing renderer emits `status.healthLabel`; JSON emits only `status.json`. A view-only extra line avoids editing the root dispatcher. | +| `src/cli/doctor.ts:94–112,1309–1325,1388` | `OAuthDoctorCheck`, `recordDoctorFailure`, `runDoctor` | Direct section printing is the current registration pattern. Put a separate section immediately after Paths, away from L1's restart-safety edits. | +| `scripts/file-size-ratchet.ts:3,145,182,207` | `THRESHOLD`, `scanRepo`, `updateBaseline`, CLI | New files must be below 2000 lines; existing recorded caps never rise. | +| `structure/AGENTS.md:27–35` | Layout rules | Structure docs have their independent 600-line cap; new source references need staging before the parent's structure gate. | +| `scripts/test-layout/layout.json:1298–1299`; `tests/fixtures/test-layout-expected.json:779` | `explicit`, independent expected roster | Register both new basenames as `cli`; regex placement alone is insufficient. | + +| Exact repository path | Change | Changed functions / content | +| --- | --- | --- | +| `src/lib/desktop-cli-record.mjs` | NEW | `desktopCliRecordPath`, `readDesktopCliRecord` | +| `src/lib/desktop-cli-record.d.mts` | NEW | Reader DTO and options | +| `src/lib/desktop-cli-handoff.mjs` | NEW | `desktopHandoffExcluded`, `planDesktopCliHandoff`, `runDesktopCliHandoff` | +| `src/lib/desktop-cli-handoff.d.mts` | NEW | Plan, exit and dependency declarations | +| `src/cli/cli-path-diagnostics.ts` | NEW | `collectCliPathDiagnostics`, `formatCliCommandLine`, `formatCliStatusHealthLabel`, `cliCommandDoctorChecks` | +| `bin/ocx.mjs` | MODIFY | Imports; `createNodeLaunchContext` extracted from existing top-level snapshot; new pre-update handoff block; Bun argv proof normalization | +| `src/service/managing-cli.ts` | MODIFY | `probeVersionOnce` environment only | +| `src/cli/status.ts` | MODIFY | `CliStatusJson`, `collectStatus` | +| `src/cli/doctor.ts` | MODIFY | `runDoctor` section registration | +| `tests/cli/ocx-launcher-desktop-handoff.test.ts` | NEW | Reader/plan/spawn/proof/probe/launcher behavior tests | +| `tests/cli/cli-path-diagnostics.test.ts` | NEW | Deterministic POSIX/Windows PATH fixtures and view tests | +| `scripts/test-layout/layout.json` | MODIFY | `explicit` two entries | +| `tests/fixtures/test-layout-expected.json` | MODIFY | Two independent entries | +| `structure/runtime.md` | MODIFY | One entrypoint-table row replaced with one row | +| `structure/cli-management.md` | MODIFY | New command-selection contract section | +| `structure/ops/service-and-sidecars.md` | MODIFY | Internal version-probe bypass paragraph | +| `docs-site/src/content/docs/getting-started/installation.md` | MODIFY | Desktop command paragraph | +| `docs-site/src/content/docs/reference/cli.md` | MODIFY | Command-selection diagnostics paragraph | + +030 owns the desktop guide en/ko and translation synchronization. The two public-text changes below are supplied here for its coordinated application; do not have two workers independently edit the same paragraphs. New `.mjs` files ship through `package.json:18–20` (`files` includes `src`); no package whitelist change is needed. + +## A4 consumer contract: reader only + +010 is final writer authority. This reader consumes only `version: 1`, `enabled`, and (when enabled) `bundle.platform` plus `bundle.cliExecutable`. It does not require or expose ownerId, installId, generation, hashes, journal payloads, bundle version or registry/rc details. Additional fields are tolerated. `pending` absent/null means settled; a present non-null pending operation is reported as `record-pending` and cannot select a target. `enabled:false` can be a small tombstone with no bundle. No new persistent fields are introduced by wp2. + +The fixed record is `~/.opencodex-desktop/cli.json` on POSIX and `%USERPROFILE%\.opencodex-desktop\cli.json` on Windows. The reader bounds descriptor reads to 64 KiB plus one sentinel byte and validates a regular file, JSON shape, platform and an absolute target. **No `OPENCODEX_DESKTOP_CLI_RECORD` environment override**: an injected `recordPath`/`home` option is sufficient for unit tests; subprocess tests use an isolated HOME and USERPROFILE. This avoids a second public runtime selector and avoids a project dotenv directing Bun-side diagnostics to a different arbitrary record. The record remains same-user configuration, not executable authenticity evidence. + +Malformed/unreadable/pending records cause a fixed-code launcher error. Missing record or disabled intent resumes the ordinary package path. After a valid target is selected, only its actual ENOENT resumes the package path. EACCES, ELOOP, ENOTDIR, non-file, relative path, self target, and other spawn failures terminate with an error. No search for another Desktop install occurs. + +### NEW `src/lib/desktop-cli-record.mjs` (complete) + +```js +import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs"; +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; + +export const DESKTOP_CLI_RECORD_MAX_BYTES = 64 * 1024; + +export function desktopCliRecordPath(options = {}) { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const home = options.home ?? (platform === "win32" ? env.USERPROFILE || homedir() : homedir()); + return (platform === "win32" ? win32 : posix).join(home, ".opencodex-desktop", "cli.json"); +} + +export function readDesktopCliRecord(options = {}) { + const platform = options.platform ?? process.platform; + const path = options.recordPath ?? desktopCliRecordPath(options); + let fd; + try { + fd = openSync(path, constants.O_RDONLY); + const info = fstatSync(fd); + if (!info.isFile()) return { state: "invalid", path, issue: "record-invalid" }; + if (info.size > DESKTOP_CLI_RECORD_MAX_BYTES) { + return { state: "invalid", path, issue: "record-too-large" }; + } + const bytes = Buffer.alloc(DESKTOP_CLI_RECORD_MAX_BYTES + 1); + let used = 0; + while (used < bytes.length) { + const n = readSync(fd, bytes, used, bytes.length - used, null); + if (n === 0) break; + used += n; + } + if (used > DESKTOP_CLI_RECORD_MAX_BYTES) { + return { state: "invalid", path, issue: "record-too-large" }; + } + let value; + try { value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used))); } + catch { return { state: "invalid", path, issue: "record-invalid" }; } + if (!value || typeof value !== "object" || Array.isArray(value) + || value.version !== 1 || typeof value.enabled !== "boolean") { + return { state: "invalid", path, issue: "record-invalid" }; + } + if (!value.enabled) return { state: "disabled", path }; + if (value.pending != null) return { state: "invalid", path, issue: "record-pending" }; + const bundle = value.bundle; + const api = platform === "win32" ? win32 : posix; + if (!bundle || typeof bundle !== "object" || Array.isArray(bundle) + || bundle.platform !== platform || typeof bundle.cliExecutable !== "string" + || bundle.cliExecutable.length > 32 * 1024 || !api.isAbsolute(bundle.cliExecutable) + || /[\x00\r\n]/.test(bundle.cliExecutable)) { + return { state: "invalid", path, issue: "record-invalid" }; + } + return { state: "ready", path, record: { + version: 1, enabled: true, + bundle: { platform, cliExecutable: bundle.cliExecutable }, + } }; + } catch (error) { + return error?.code === "ENOENT" + ? { state: "missing", path } + : { state: "unreadable", path, issue: "record-unreadable" }; + } finally { + if (fd !== undefined) closeSync(fd); + } +} +``` + +### NEW `src/lib/desktop-cli-record.d.mts` (complete) + +```ts +export const DESKTOP_CLI_RECORD_MAX_BYTES: number; +export type DesktopCliRecord = Readonly<{ + version: 1; + enabled: true; + bundle: Readonly<{ platform: NodeJS.Platform; cliExecutable: string }>; +}>; +export type DesktopCliRecordIssue = "record-invalid" | "record-too-large" | "record-pending" | "record-unreadable"; +export type DesktopCliRecordRead = + | { state: "missing" | "disabled"; path: string } + | { state: "ready"; path: string; record: DesktopCliRecord } + | { state: "invalid" | "unreadable"; path: string; issue: DesktopCliRecordIssue }; +export type DesktopCliRecordOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + home?: string; + recordPath?: string; +}; +export function desktopCliRecordPath(options?: DesktopCliRecordOptions): string; +export function readDesktopCliRecord(options?: DesktopCliRecordOptions): DesktopCliRecordRead; +``` + +### NEW `src/lib/desktop-cli-handoff.mjs` (complete) + +```js +import { spawn } from "node:child_process"; +import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { posix, win32 } from "node:path"; +import { readDesktopCliRecord } from "./desktop-cli-record.mjs"; +import { isCodexCliUpdateInspectionArgv } from "../update/codex-cli-update-launch-policy.mjs"; + +const PROOF_PREFIX = "--ocx-internal-launch-proof="; + +export function desktopHandoffExcluded(argv, env = process.env) { + const args = argv.filter(arg => !arg.startsWith(PROOF_PREFIX)); + return env.OCX_NO_DESKTOP_HANDOFF === "1" + || ["update", "uninstall", "remove"].includes(args[0]) + || (args[0] ?? "").startsWith("__") + || isCodexCliUpdateInspectionArgv(["node", "ocx.mjs", ...args]); +} + +export function planDesktopCliHandoff(input = {}, deps = {}) { + const argv = input.argv ?? process.argv.slice(2); + const env = input.env ?? process.env; + if (desktopHandoffExcluded(argv, env)) return { kind: "continue", reason: "excluded" }; + const read = input.recordRead ?? readDesktopCliRecord({ env, platform: input.platform }); + if (read.state === "missing" || read.state === "disabled") { + return { kind: "continue", reason: read.state }; + } + if (read.state !== "ready") return { kind: "error", issue: read.issue }; + const platform = input.platform ?? process.platform; + const api = platform === "win32" ? win32 : posix; + const target = read.record.bundle.cliExecutable; + if (!api.isAbsolute(target)) return { kind: "error", issue: "target-invalid" }; + const stat = deps.stat ?? statSync; + const access = deps.access ?? accessSync; + const realpath = deps.realpath ?? realpathSync; + try { + if (!stat(target).isFile()) return { kind: "error", issue: "target-invalid" }; + access(target, platform === "win32" ? constants.F_OK : constants.X_OK); + const physical = realpath(target); + const key = path => platform === "win32" ? path.toLowerCase() : path; + for (const self of input.selfPaths ?? [process.argv[1], process.execPath]) { + if (!self) continue; + let own; + try { own = realpath(self); } + catch (error) { if (error?.code === "ENOENT") continue; throw error; } + if (key(own) === key(physical)) return { kind: "error", issue: "target-self" }; + } + return { kind: "handoff", target: physical }; + } catch (error) { + return error?.code === "ENOENT" + ? { kind: "continue", reason: "target-missing" } + : { kind: "error", issue: "target-unusable" }; + } +} + +export function runDesktopCliHandoff(plan, launch, deps = {}) { + const spawnChild = deps.spawn ?? spawn; + const parent = deps.parent ?? process; + const platform = deps.platform ?? process.platform; + const argv = launch.argv.filter(arg => !arg.startsWith(PROOF_PREFIX)); + const env = { ...launch.env, OCX_NO_DESKTOP_HANDOFF: "1", OCX_NODE_LAUNCH_CONTEXT: launch.context }; + delete env.OCX_BUN_RUNTIME_SOURCE; + delete env.OCX_BUN_RUNTIME_PATH; + return new Promise(resolveExit => { + let child; + try { + child = spawnChild(plan.target, [`${PROOF_PREFIX}${launch.proof}`, ...argv], { + stdio: "inherit", shell: false, windowsHide: true, env, + }); + } catch (error) { + resolveExit(error?.code === "ENOENT" ? { kind: "continue" } : { kind: "error", issue: "spawn-failed" }); + return; + } + const forwarded = platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"]; + const handlers = forwarded.map(signal => { + const handler = () => { try { child.kill(signal); } catch { /* child already exited */ } }; + parent.on(signal, handler); + return [signal, handler]; + }); + const clear = () => { for (const [signal, handler] of handlers) parent.removeListener(signal, handler); }; + let done = false; + const finish = result => { if (done) return; done = true; clear(); resolveExit(result); }; + child.once("error", error => finish(error?.code === "ENOENT" + ? { kind: "continue" } : { kind: "error", issue: "spawn-failed" })); + child.once("exit", (code, signal) => finish({ kind: "exit", code: code ?? 1, signal })); + }); +} +``` + +### NEW `src/lib/desktop-cli-handoff.d.mts` (complete) + +```ts +import type { spawn } from "node:child_process"; +import type { accessSync, statSync } from "node:fs"; +import type { DesktopCliRecordRead, DesktopCliRecordIssue } from "./desktop-cli-record.mjs"; +export type DesktopHandoffIssue = DesktopCliRecordIssue | "target-invalid" | "target-self" | "target-unusable" | "spawn-failed"; +export type DesktopCliHandoffPlan = + | { kind: "continue"; reason: "excluded" | "missing" | "disabled" | "target-missing" } + | { kind: "error"; issue: DesktopHandoffIssue } + | { kind: "handoff"; target: string }; +export type DesktopCliHandoffExit = + | { kind: "continue" } + | { kind: "error"; issue: "spawn-failed" } + | { kind: "exit"; code: number; signal: NodeJS.Signals | null }; +export function desktopHandoffExcluded(argv: string[], env?: NodeJS.ProcessEnv): boolean; +export function planDesktopCliHandoff(input?: { + argv?: string[]; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; + recordRead?: DesktopCliRecordRead; selfPaths?: (string | undefined)[]; +}, deps?: { stat?: typeof statSync; access?: typeof accessSync; realpath?: (path: string) => string }): DesktopCliHandoffPlan; +export function runDesktopCliHandoff(plan: Extract, launch: { + argv: string[]; env: NodeJS.ProcessEnv; proof: string; context: string; +}, deps?: { spawn?: typeof spawn; parent?: Pick; platform?: NodeJS.Platform }): Promise; +``` + +`realpathSync`'s overloaded declaration is deliberately narrowed to the string call used here. `selfPaths` includes both the launcher module and executing binary. Selected-target races are still possible between access/realpath and spawn; the spawn result is authoritative. Windows checks existence/regular-file and lets CreateProcess decide executability; POSIX checks X_OK first. + +## Observe-only diagnostics + +`pathFirst` is the first runnable PATH candidate, never a claim about aliases/functions or shell caches. POSIX empty PATH elements mean cwd; relative elements resolve against cwd. Unset PATH has no observed candidates. Windows checks PATHEXT in declared order, case-insensitively, with an extensionless candidate last; surrounding directory quotes are removed. A separate `currentDirectoryCandidate` observes cmd's possible cwd override and never changes `pathFirst`. This distinction applies even when PowerShell's command-selection rules differ. + +Expected executable is the fixed owned POSIX shim `~/.opencodex-desktop/bin/ocx`, or Windows's recorded bundled `ocx.exe`. An npm executable first on PATH remains an issue even if that launcher later hands off successfully. Missing/unusable target, missing shim, incomplete record and scan errors remain visible. No `which`, `where`, subprocess version probe or source of shell startup scripts is used. + +### NEW `src/cli/cli-path-diagnostics.ts` (complete) + +```ts +import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; +import { readDesktopCliRecord, type DesktopCliRecordRead, type DesktopCliRecordIssue } from "../lib/desktop-cli-record.mjs"; +import { redactUserPath } from "../lib/redact"; + +export type CliCommandIssue = DesktopCliRecordIssue + | "desktop-target-missing" | "desktop-target-unusable" + | "expected-command-missing" | "expected-command-unusable" + | "path-missing" | "path-first-not-desktop" | "path-unreadable" + | "path-scan-truncated" | "windows-current-directory-shadow"; +export type CliPathCandidate = { + path: string; realpath: string; pathIndex: number; extension: string; +}; +export type CliPathDiagnostics = { + configured: boolean; + recordState: DesktopCliRecordRead["state"]; + expectedExecutable: string | null; + handoffTarget: string | null; + candidates: CliPathCandidate[]; + pathFirst: CliPathCandidate | null; + desktopFirstOnPath: boolean | null; + currentDirectoryCandidate: CliPathCandidate | null; + shellResolution: "unobserved"; + issues: CliCommandIssue[]; +}; +export type CliPathObservation = + | { kind: "usable"; realpath: string } + | { kind: "missing" | "unusable" | "unreadable" }; +export type CliPathDiagnosticOptions = { + platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; cwd?: string; home?: string; + recordRead?: DesktopCliRecordRead; + observe?: (path: string) => CliPathObservation; +}; + +export function collectCliPathDiagnostics(options: CliPathDiagnosticOptions = {}): CliPathDiagnostics { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const api = platform === "win32" ? win32 : posix; + const cwd = options.cwd ?? process.cwd(); + const home = options.home ?? (platform === "win32" ? env.USERPROFILE || homedir() : homedir()); + const read = options.recordRead ?? readDesktopCliRecord({ platform, env, home }); + const configured = read.state === "ready"; + const target = configured && read.state === "ready" ? read.record.bundle.cliExecutable : null; + const expected = configured ? (platform === "win32" ? target : api.join(home, ".opencodex-desktop", "bin", "ocx")) : null; + const issues: CliCommandIssue[] = []; + const add = (issue: CliCommandIssue): void => { if (!issues.includes(issue)) issues.push(issue); }; + if (read.state === "invalid" || read.state === "unreadable") add(read.issue); + const observe = options.observe ?? ((path: string): CliPathObservation => { + try { + if (!statSync(path).isFile()) return { kind: "unusable" }; + accessSync(path, platform === "win32" ? constants.F_OK : constants.X_OK); + return { kind: "usable", realpath: realpathSync(path) }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return { kind: code === "ENOENT" || code === "ENOTDIR" ? "missing" : code === "EACCES" ? "unusable" : "unreadable" }; + } + }); + const key = (path: string): string => platform === "win32" ? path.toLowerCase() : path; + const same = (a: string, b: string): boolean => key(a) === key(b); + const envValue = (name: string): string | undefined => platform === "win32" + ? env[Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase()) ?? name] : env[name]; + const path = envValue("PATH"); + const rawExtensions = platform === "win32" + ? (envValue("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(ext => /^\.[A-Za-z0-9]+$/.test(ext)) : []; + const uniqueExtensions = [...new Set(rawExtensions.map(ext => ext.toLowerCase()))]; + const extensions = platform === "win32" ? [...uniqueExtensions.slice(0, 32), ""] : [""]; + if (uniqueExtensions.length > 32) add("path-scan-truncated"); + const directories = path === undefined ? [] : path.split(platform === "win32" ? ";" : ":"); + if (directories.length > 256) add("path-scan-truncated"); + const candidates: CliPathCandidate[] = []; + const scan = (directory: string, pathIndex: number): CliPathCandidate[] => { + const result: CliPathCandidate[] = []; + const unquoted = platform === "win32" && directory.startsWith('"') && directory.endsWith('"') ? directory.slice(1, -1) : directory; + for (const extension of extensions) { + const candidate = api.resolve(cwd, unquoted || ".", `ocx${extension}`); + const found = observe(candidate); + if (found.kind === "unreadable") add("path-unreadable"); + if (found.kind === "usable") result.push({ path: candidate, realpath: found.realpath, pathIndex, extension }); + } + return result; + }; + for (const [index, directory] of directories.slice(0, 256).entries()) candidates.push(...scan(directory, index)); + const pathFirst = candidates[0] ?? null; + const currentDirectoryCandidate = platform === "win32" ? scan(cwd, -1)[0] ?? null : null; + let expectedRealpath: string | null = null; + if (target) { + const found = observe(target); + if (found.kind === "missing") add("desktop-target-missing"); + else if (found.kind !== "usable") add("desktop-target-unusable"); + } + if (expected) { + const found = observe(expected); + if (found.kind === "missing") add("expected-command-missing"); + else if (found.kind !== "usable") add("expected-command-unusable"); + else expectedRealpath = found.realpath; + if (!pathFirst) add("path-missing"); + else if (!same(pathFirst.path, expected) && (!expectedRealpath || !same(pathFirst.realpath, expectedRealpath))) add("path-first-not-desktop"); + if (currentDirectoryCandidate && !same(currentDirectoryCandidate.path, expected) + && (!expectedRealpath || !same(currentDirectoryCandidate.realpath, expectedRealpath))) add("windows-current-directory-shadow"); + } + return { + configured, recordState: read.state, expectedExecutable: expected, handoffTarget: target, + candidates, pathFirst, + desktopFirstOnPath: configured && !issues.includes("path-unreadable") && !issues.includes("path-scan-truncated") + ? Boolean(pathFirst && expected && expectedRealpath + && (same(pathFirst.path, expected) || same(pathFirst.realpath, expectedRealpath))) : null, + currentDirectoryCandidate, shellResolution: "unobserved", issues, + }; +} + +export function formatCliCommandLine(value: CliPathDiagnostics): string { + const first = value.pathFirst ? redactUserPath(value.pathFirst.path).replace(/[\x00-\x1f\x7f]/g, "?") : "none observed"; + return `ocx command: PATH first=${first}; Desktop=${!value.configured ? "not configured" : value.desktopFirstOnPath === null ? "unobserved" : value.desktopFirstOnPath ? "first" : "not first"}; shell=unobserved${value.issues.length ? `; issues=${value.issues.join(",")}` : ""}`; +} + +export function formatCliStatusHealthLabel(health: string, value: CliPathDiagnostics, local: boolean): string { + return `${health}${local ? " (local)" : ""}\n ${formatCliCommandLine(value)}`; +} + +export function cliCommandDoctorChecks(value: CliPathDiagnostics): { level: "OK" | "WARN" | "FAIL"; message: string }[] { + const hard = value.issues.some(issue => issue.startsWith("record-") || issue.startsWith("desktop-target-") || issue.startsWith("expected-command-")); + return [{ level: hard ? "FAIL" : value.issues.length ? "WARN" : "OK", message: formatCliCommandLine(value) }]; +} +``` + +Scan bounds are explicit: 256 PATH elements and 32 distinct PATHEXT extensions. A truncated scan is WARN even when Desktop appears first; do not present complete candidate inventory. `shellResolution` is always `unobserved`, including an otherwise OK check. Configured false with a missing/disabled record is informational OK, not a promise that Desktop is configured. + +## MODIFY diffs + +The following exact baseline diffs are generated from the inspected 68c9d35457 files. The factory retains the original inspection snapshot, including `configDir`, relative CODEX_CLI_PATH resolution, manager roots and case-insensitive deletion of duplicated inspection environment variables. It returns one proof/context/env object; Desktop and Bun receive the same freshly captured object. It is **called** after the inspection classification and before handoff, not during module import. + +### MODIFY `bin/ocx.mjs` + +```diff +--- a/bin/ocx.mjs ++++ b/bin/ocx.mjs +@@ -9,6 +9,7 @@ + * src/cli/index.ts — only the published npm/pnpm `bin` routes through here.) + */ + import { spawn, spawnSync } from "node:child_process"; ++import { planDesktopCliHandoff, runDesktopCliHandoff } from "../src/lib/desktop-cli-handoff.mjs"; + import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; + import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs"; + import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; +@@ -961,6 +962,32 @@ + process.exit(1); + } + ++const { launchProof, launchContext, inheritedEnv } = createNodeLaunchContext(); ++const desktopPlan = planDesktopCliHandoff({ selfPaths: [fileURLToPath(import.meta.url), process.execPath] }); ++if (desktopPlan.kind === "error") { ++ console.error(`opencodex: Desktop CLI handoff refused (${desktopPlan.issue}). Repair or remove the terminal command in Desktop.`); ++ process.exit(1); ++} ++if (desktopPlan.kind === "handoff") { ++ const result = await runDesktopCliHandoff(desktopPlan, { ++ argv: process.argv.slice(2), env: inheritedEnv, proof: launchProof, context: launchContext, ++ }); ++ if (result.kind === "error") { ++ console.error(`opencodex: Desktop CLI handoff failed (${result.issue}). Repair or remove the terminal command in Desktop.`); ++ process.exit(1); ++ } ++ if (result.kind === "exit") { ++ if (result.signal) { ++ process.kill(process.pid, result.signal); ++ } else { ++ process.exit(result.code); ++ } ++ // Signal delivery is asynchronous; never continue into package repair or Bun lookup. ++ await new Promise(() => {}); ++ } ++ // Only target ENOENT reaches the ordinary package path below. ++} ++ + if (process.argv[2] === "update" && installMethod === "mise") { + if (installOwnership.owner) { + console.error( +@@ -1015,64 +1042,68 @@ + // billing and prevents it from redirecting the subscriber's OAuth bearer. + // Disabling Bun's dotenv wholesale with --no-env-file is NOT an option: config + // interpolation and provider settings legitimately read the project environment. +-const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] +- .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); +-// A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the +-// ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture +-// the absolute form here, in the launcher, while the original cwd is still authoritative; +-// resolving it later would silently reinterpret it against a different working directory. +-// +-// A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver +-// deliberately hands those to executable lookup along PATH. Rewriting it to `/codex` +-// would make the inspector treat it as an explicit path and stop searching PATH entirely. +-const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== "" +- ? process.env.CODEX_CLI_PATH +- : null; +-const preBunCodexCliPath = configuredCodexCliPath !== null +- && (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)) +- ? resolve(configuredCodexCliPath) +- : configuredCodexCliPath; +-const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; +-const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; +-const preBunCodexCliManagerRoots = Object.fromEntries( +- CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { +- const value = process.env[name]; +- return typeof value === "string" && value !== "" ? [[name, value]] : []; +- }), +-); +-const launchProof = randomBytes(32).toString("base64url"); +-const launchContext = JSON.stringify({ +- version: 1, +- proof: launchProof, +- anthropicEnvSlots: preBunAnthropicSlots, +- codexCliInspectionEnv: codexCliUpdateInspection ? { +- codexCliPath: preBunCodexCliPath, +- path: preBunPath, +- pathExt: preBunPathExt, +- managerRoots: preBunCodexCliManagerRoots, +- configDir: configDir(), +- } : null, +-}); +-// The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as +-// proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than +-// from the live environment. Inheriting them again would spend the 32,767-character Windows +-// environment block twice, so a large-but-valid shell environment could stop the Bun child +-// from spawning and fail the command before it reports anything. Drop the duplicates for the +-// one-shot inspection launch only; every other launch inherits the environment unchanged. +-// Windows environment names are case-insensitive, but this spread produces an ordinary +-// case-sensitive object, and a real Windows environment commonly spells the variable `Path`. +-// Deleting only the canonical upper-case spelling would silently leave that copy behind and +-// reintroduce the duplication this block exists to prevent, so match on the lowercase form. +-const inheritedEnv = { ...process.env }; +-if (codexCliUpdateInspection) { +- const snapshotted = new Set( +- ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()), ++function createNodeLaunchContext() { ++ const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] ++ .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); ++ // A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the ++ // ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture ++ // the absolute form here, in the launcher, while the original cwd is still authoritative; ++ // resolving it later would silently reinterpret it against a different working directory. ++ // ++ // A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver ++ // deliberately hands those to executable lookup along PATH. Rewriting it to `/codex` ++ // would make the inspector treat it as an explicit path and stop searching PATH entirely. ++ const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== "" ++ ? process.env.CODEX_CLI_PATH ++ : null; ++ const preBunCodexCliPath = configuredCodexCliPath !== null ++ && (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)) ++ ? resolve(configuredCodexCliPath) ++ : configuredCodexCliPath; ++ const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; ++ const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; ++ const preBunCodexCliManagerRoots = Object.fromEntries( ++ CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { ++ const value = process.env[name]; ++ return typeof value === "string" && value !== "" ? [[name, value]] : []; ++ }), + ); +- for (const name of Object.keys(inheritedEnv)) { +- if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name]; +- } +-} +-const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2)], { ++ const launchProof = randomBytes(32).toString("base64url"); ++ const launchContext = JSON.stringify({ ++ version: 1, ++ proof: launchProof, ++ anthropicEnvSlots: preBunAnthropicSlots, ++ codexCliInspectionEnv: codexCliUpdateInspection ? { ++ codexCliPath: preBunCodexCliPath, ++ path: preBunPath, ++ pathExt: preBunPathExt, ++ managerRoots: preBunCodexCliManagerRoots, ++ configDir: configDir(), ++ } : null, ++ }); ++ // The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as ++ // proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than ++ // from the live environment. Inheriting them again would spend the 32,767-character Windows ++ // environment block twice, so a large-but-valid shell environment could stop the Bun child ++ // from spawning and fail the command before it reports anything. Drop the duplicates for the ++ // one-shot inspection launch only; every other launch inherits the environment unchanged. ++ // Windows environment names are case-insensitive, but this spread produces an ordinary ++ // case-sensitive object, and a real Windows environment commonly spells the variable `Path`. ++ // Deleting only the canonical upper-case spelling would silently leave that copy behind and ++ // reintroduce the duplication this block exists to prevent, so match on the lowercase form. ++ const inheritedEnv = { ...process.env }; ++ if (codexCliUpdateInspection) { ++ const snapshotted = new Set( ++ ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()), ++ ); ++ for (const name of Object.keys(inheritedEnv)) { ++ if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name]; ++ } ++ } ++ return { launchProof, launchContext, inheritedEnv }; ++} ++ ++const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2).filter(arg => !arg.startsWith(NODE_LAUNCH_PROOF_PREFIX))], { + stdio: "inherit", + // A headless Windows parent (Task Scheduler, dashboard restart, shortcut) has no + // console to inherit. Without this flag Windows allocates a visible console for +``` + +### MODIFY `src/service/managing-cli.ts` + +```diff +--- a/src/service/managing-cli.ts ++++ b/src/service/managing-cli.ts +@@ -81,6 +81,7 @@ + windowsShim ? ["/c", shimCommand] : [...args, "--version"], + { + timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe", ++ env: { ...deps.env, OCX_NO_DESKTOP_HANDOFF: "1" }, + windowsHide: true, ...(windowsShim ? { windowsVerbatimArguments: true } : {}), + }, + ) as SpawnSyncReturns; +``` + +### MODIFY `src/cli/status.ts` + +```diff +--- a/src/cli/status.ts ++++ b/src/cli/status.ts +@@ -1,3 +1,4 @@ ++import { collectCliPathDiagnostics, formatCliStatusHealthLabel, type CliPathDiagnostics } from "./cli-path-diagnostics"; + import type { CodexMainAccountPolicyHealth } from "../oauth/health"; + import { durableBunRuntime } from "../lib/bun-runtime"; + import { existsSync, readFileSync } from "node:fs"; +@@ -215,6 +216,7 @@ + * ignores the key is unaffected, and `proxyVersion` is null when nothing is live. + */ + versionSkew: VersionSkew; ++ cliCommand: CliPathDiagnostics; + }; + + export type CliStatusView = { +@@ -680,6 +682,7 @@ + + /** `mainAccountPolicy`: only `--json` asks; the human report reads the same accounts once via OAuth health. */ + export async function collectStatus(options: { mainAccountPolicy?: boolean } = {}): Promise { ++ const cliCommand = collectCliPathDiagnostics(); + const configDiagnostics = readConfigDiagnostics(); + const config = configDiagnostics.config; + const claudeDesktop = { +@@ -869,7 +872,7 @@ + + return { + proxyLabel, +- healthLabel: health.label, ++ healthLabel: formatCliStatusHealthLabel(health.label, cliCommand, remoteHub.connected), + json: { + ...(mainAccountHardLock ? { mainAccountHardLock } : {}), + schemaVersion: 1, +@@ -932,6 +935,7 @@ + // fact about this install, not about the Codex runtime, and filing it there would + // print it under the wrong heading (#2701). + versionSkew, ++ cliCommand, + }, + }; + } +``` + +### MODIFY `src/cli/doctor.ts` + +```diff +--- a/src/cli/doctor.ts ++++ b/src/cli/doctor.ts +@@ -7,6 +7,7 @@ + * it never sets proxy env, relocates state dirs, mutates quota, or changes + * networking. See devlog/_plan/260630_wsl-account-autoswitch/30_*. + */ ++import { cliCommandDoctorChecks, collectCliPathDiagnostics } from "./cli-path-diagnostics"; + import { accessSync, constants, existsSync, readFileSync } from "node:fs"; + import { homedir } from "node:os"; + import { dirname, join } from "node:path"; +@@ -1324,6 +1325,12 @@ + console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`); + } + ++ console.log("\nocx command selection"); ++ for (const check of cliCommandDoctorChecks(collectCliPathDiagnostics())) { ++ console.log(` ${check.level === "OK" ? "ok " : "!! "} ${check.message}`); ++ if (check.level === "FAIL") recordDoctorFailure(); ++ } ++ + // Runs without the proxy on purpose: the worst accumulation happens when the proxy will + // not start, which is exactly when the in-process periodic reclaim never ticks. + const reclaimTemps = args.includes(RECLAIM_RESPONSE_TEMPS_FLAG); +``` + +### MODIFY `scripts/test-layout/layout.json` + +```diff +--- a/scripts/test-layout/layout.json ++++ b/scripts/test-layout/layout.json +@@ -1295,6 +1295,8 @@ + "oauth-store-multi.test.ts": "oauth", + "oauth-tos-warning.test.ts": "gui", + "oauth-upsert-preserves-api-key.test.ts": "oauth", ++ "ocx-launcher-desktop-handoff.test.ts": "cli", ++ "cli-path-diagnostics.test.ts": "cli", + "ocx-launcher-runtime.test.ts": "cli", + "ocx-launcher-source.test.ts": "cli", + "ocx-run.test.ts": "cli", +``` + +### MODIFY `tests/fixtures/test-layout-expected.json` + +```diff +--- a/tests/fixtures/test-layout-expected.json ++++ b/tests/fixtures/test-layout-expected.json +@@ -776,6 +776,7 @@ + "oauth-refresh-lock-multiprocess.test.ts": "oauth", "oauth-refresh.test.ts": "oauth", + "oauth-status-privacy.test.ts": "oauth", "oauth-store-multi.test.ts": "oauth", + "oauth-tos-warning.test.ts": "gui", "oauth-upsert-preserves-api-key.test.ts": "oauth", ++ "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", + "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", + "ocx-run.test.ts": "cli", "ollama-native-parser.test.ts": "providers/ollama", + "ollama-native-reasoning-wire.test.ts": "providers/ollama", +``` + +### MODIFY `structure/runtime.md` + +```diff +--- a/structure/runtime.md ++++ b/structure/runtime.md +@@ -120,7 +120,7 @@ + + | Path | Responsibility | + | --- | --- | +-| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | ++| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` reads the bounded settled Desktop command record and `src/lib/desktop-cli-handoff.mjs` delegates eligible commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | + | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | + | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | + | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | +``` + +### MODIFY `structure/cli-management.md` + +```diff +--- a/structure/cli-management.md ++++ b/structure/cli-management.md +@@ -105,3 +105,7 @@ + `src/cli/account-api.ts` forwards explicit quota/refresh intent to the existing per-key owner; ordinary key listing omits both. Opt-in requests reject redirects. `src/cli/account-key-quota.ts` rebuilds the public ProviderQuota projection, including custom windows and credits, with finite-field validation and no credential or private publication fields. Missing mode evidence is unverified/nonzero. Human rendering separates probe/passive/unsupported, unavailable and unmeasured from actual zero; Codex/OAuth rendering remains separate. This read can invoke upstream quota work but does not change key selection or provider aggregate caches. Account list/current rows additionally project, through `src/cli/account-next-actions.ts`, only an allow-listed server health label, a locally generated recovery command built from shell-safe selectors (never server summary/action text), and the Codex boolean `creditsAfterLimit` as read-only consent readback; empty listings add a fixed next-action note to human output and JSON `notes`. + + The operating guide's eight-domain workflow table is navigation rather than parity measurement. Handwritten JSON, recipe, recovery and target docs qualify bounded windows, model-only search, partial/default results and human-only/session actions. Generated chapter counts still derive exclusively from capability declarations. ++ ++## ocx command selection ++ ++`src/cli/cli-path-diagnostics.ts` reads the settled Desktop record through `src/lib/desktop-cli-record.mjs` and observes regular executable candidates on this process's PATH without executing them. `pathFirst` follows PATH/PATHEXT order, including cwd-relative and empty entries; Windows's possible cmd current-directory candidate is separate. The expected command is the owned POSIX shim or the recorded Windows bundled executable. A stale package candidate first on PATH is reported even if its current launcher would hand off. `src/cli/status.ts` exposes additive `cliCommand` JSON and a human-view line; `src/cli/doctor.ts` prints the separate “ocx command selection” checks. Neither observation supplies runtime ownership or service control authority. Parent-shell aliases, functions and caches remain unobserved. Invalid records and unusable Desktop targets are FAIL; PATH ordering and incomplete scans are WARN. +``` + +### MODIFY `structure/ops/service-and-sidecars.md` + +```diff +--- a/structure/ops/service-and-sidecars.md ++++ b/structure/ops/service-and-sidecars.md +@@ -539,3 +539,5 @@ + Desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, the login registration (macOS: launchd; Linux: the `~/.config/autostart/OpenCodex.desktop` entry that auto-launch writes, credited only when `XDG_CONFIG_HOME` is unset or `~/.config`; it must launch an unquoted absolute `opencodex-desktop` with exactly `--autostart`, without conditional keys, and not be hidden or disabled) and exact parent/child executable paths (Linux: `/proc//exe` and the parent pid from `/proc//stat`) without mutating them. A durable desktop claim survives a failed identity or supervision check; only fresh matching identity, enabled login registration, and live supervision grant protection. On macOS, ownership and PID are re-read before crediting the result; on Linux, the complete evidence chain is read twice and both reads must agree. The startup-health subprocess uses `selfLaunchArgv` to support both source and compiled entrypoints. + + On Linux, a dashboard update worker started from the systemd user service is launched through an executable regular file at a trusted absolute path — `/usr/bin/systemd-run`, `/bin/systemd-run`, `/usr/local/bin/systemd-run` (local installs), or `/run/current-system/sw/bin/systemd-run` (the NixOS layout) — with `--user --scope --quiet --collect` (`src/update/worker-launch.ts`), so it leaves the service cgroup before the updater stops `opencodex-proxy.service`; the default `KillMode=control-group` otherwise kills it with the proxy (#5750). The inherited `PATH` is never searched, and each candidate's resolved target — plus every ancestor directory able to substitute it — must be root-owned and not group/world-writable: a trusted-path symlink into a user-replaceable directory is skipped, as is a group-writable `/usr/local/bin`, rather than exec'd under the service account. Candidates are tried in order and a path whose no-op scope probe fails falls through to the next trusted path; the probe applies only when `INVOCATION_ID` is set, and every other case keeps the plain detached spawn. The management route resolves the launcher with `resolveSystemdRunAsync` before spawning, so first-request probing overlaps other work instead of blocking the event loop for up to twenty seconds. `--scope` moves `systemd-run` itself into the scope and then execs the worker, so the recorded PID is the worker's (`tests/update/update-worker-launch.test.ts`). ++ ++Internal version observations in `src/service/managing-cli.ts` (`probeVersionOnce`) set `OCX_NO_DESKTOP_HANDOFF=1` in the spawned child's environment so the selected package executable reports its own version. This flag suppresses the package launcher's Desktop handoff only; it does not bypass service ownership or update guards. Normal user `ocx --version` remains eligible for handoff. +``` + +### MODIFY `docs-site/src/content/docs/getting-started/installation.md` + +```diff +--- a/docs-site/src/content/docs/getting-started/installation.md ++++ b/docs-site/src/content/docs/getting-started/installation.md +@@ -6,6 +6,12 @@ + opencodex installs two equivalent command names, `ocx` and `opencodex`. Both launch the same small + local HTTP server (built on Bun). Model requests go to the provider selected by routing; optional + vision and web-search sidecars can also use your ChatGPT login when a routed model needs them. ++ ++## Desktop terminal command ++ ++A stable OpenCodex Desktop install configures `ocx` for new terminals when the app starts. Open a new terminal and run `ocx status` or `ocx doctor` to inspect the first executable on PATH. POSIX uses the Desktop-owned shim; Windows uses the bundled `ocx.exe`. Existing terminals, aliases, shell caches and later PATH changes can still select another command; a conflicting Windows system PATH entry can also win. Use the app’s Terminal command settings to repair, disable or remove this configuration. AppImage is excluded. ++ ++A current npm/pnpm package launcher that still runs delegates eligible commands to the Desktop CLI when its record is enabled. Package update/removal and internal inspection keep their existing path. `OCX_NO_DESKTOP_HANDOFF=1` suppresses that package handoff for one invocation; it does not change a direct Desktop shim or PATH configuration. + + ## Prerequisites + +``` + +### MODIFY `docs-site/src/content/docs/reference/cli.md` + +```diff +--- a/docs-site/src/content/docs/reference/cli.md ++++ b/docs-site/src/content/docs/reference/cli.md +@@ -254,6 +254,8 @@ + version mismatch. `unknown` does not confirm matching builds. Offline help, + local configuration and local Lab inspection do not require startup. + ++`ocx status --json` includes `cliCommand`: configured Desktop intent, the expected executable, observed PATH candidates, `pathFirst`, `desktopFirstOnPath`, issue codes, and `shellResolution: "unobserved"`. The human status report prints one command-selection line. `ocx doctor` adds an “ocx command selection” section: invalid records or missing/unusable Desktop targets fail the check, while PATH ordering conflicts and incomplete scans warn. These read-only observations do not execute candidates, resolve parent-shell aliases/functions, or identify the proxy’s runtime owner. On Windows, a possible cmd current-directory candidate is reported separately from PATH order. ++ + Output flags are per command. `doctor` rejects `--json` with exit 2; + [`v2` (family reference)](/reference/cli/agents/) + supports `--json` for local and `--live` targets. Even for JSON-capable management commands, API failures +``` + +The `healthLabel` change is a deliberate **view-only adapter**: the unchanged `handleStatus` already prints this string and prints JSON from `status.json` alone. The line is local to this CLI even when connected to a hub. Do not print inside `collectStatus`; doing so would contaminate JSON callers. This adapter does make the human-view health string multiline. If review requires a dedicated renderer field, report the necessary `index.ts` scope expansion to parent before changing it. The JSON health message remains a single unchanged value. + +## Complete new regression files + +The runnable code below creates only temp fixtures, uses repository-root helpers, does not source startup files, and never changes the user's home or installed Desktop. Native Windows process signal semantics are not inferred from POSIX integration tests; Windows policy/observation fixtures run on every host and 030 owns native Windows smoke evidence. + +### NEW `tests/cli/ocx-launcher-desktop-handoff.test.ts` (complete) + +```ts +import { afterEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync, type statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { readDesktopCliRecord, desktopCliRecordPath, DESKTOP_CLI_RECORD_MAX_BYTES, type DesktopCliRecordRead } from "../../src/lib/desktop-cli-record.mjs"; +import { desktopHandoffExcluded, planDesktopCliHandoff, runDesktopCliHandoff } from "../../src/lib/desktop-cli-handoff.mjs"; +import { initializeNodeLauncherContext } from "../../src/cli/launcher-context"; +import { buildNativeClaudeEnv } from "../../src/cli/claude"; +import { observeManagingClis } from "../../src/service/managing-cli"; +import { repoPath } from "../helpers/repo-root"; + +const roots: string[] = []; +const PREFIX = "--ocx-internal-launch-proof="; +const proof = "A".repeat(43); +const context = JSON.stringify({ version: 1, proof, anthropicEnvSlots: ["ANTHROPIC_API_KEY"], codexCliInspectionEnv: null }); +afterEach(() => { + initializeNodeLauncherContext(["bun", "index.ts"], {}); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +function box(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-handoff-")); roots.push(root); return root; +} +function ready(target = "/fixture/desktop/ocx"): DesktopCliRecordRead { + return { state: "ready", path: "/fixture/cli.json", record: { + version: 1, enabled: true, bundle: { platform: "linux", cliExecutable: target }, + } }; +} +const stat = (() => ({ isFile: () => true })) as unknown as typeof statSync; +const deps = { stat, access: () => {}, realpath: (path: string) => path }; +const coded = (code: string): NodeJS.ErrnoException => Object.assign(new Error("fixture"), { code }); + +function fakeSpawn(error?: string, exitSignal: NodeJS.Signals | null = null) { + const parent = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { kill: (signal: string) => boolean }; + const killed: string[] = []; + child.kill = signal => { killed.push(signal); return true; }; + let observed: { target: string; argv: string[]; options: Record } | undefined; + const run = ((target: string, argv: string[], options: Record) => { + observed = { target, argv, options }; + queueMicrotask(() => { + parent.emit("SIGTERM"); + if (error) { child.emit("error", coded(error)); child.emit("exit", 99, null); } + else child.emit("exit", 17, exitSignal); + }); + return child; + }) as unknown as typeof spawn; + return { run, parent, killed, observed: () => observed }; +} + +describe("Desktop record and handoff policy", () => { + test("fixed record location and injected reader path", () => { + expect(desktopCliRecordPath({ platform: "darwin", home: "/Users/example" })).toBe("/Users/example" + "/.opencodex-desktop/cli.json"); + expect(desktopCliRecordPath({ platform: "win32", env: { USERPROFILE: "C:\\Users\\example" } })).toBe("C:\\Users\\example\\.opencodex-desktop\\cli.json"); + const root = box(); const recordPath = join(root, "cli.json"); + expect(readDesktopCliRecord({ recordPath }).state).toBe("missing"); + writeFileSync(recordPath, '{"version":1,"enabled":false}'); + expect(readDesktopCliRecord({ recordPath }).state).toBe("disabled"); + writeFileSync(recordPath, JSON.stringify({ version: 1, enabled: true, bundle: { platform: process.platform, cliExecutable: join(root, "ocx") }, ownerId: "ignored" })); + expect(readDesktopCliRecord({ recordPath }).state).toBe("ready"); + }); + test("reader refuses oversized, malformed, pending and wrong-platform records", () => { + const root = box(); const recordPath = join(root, "cli.json"); + const valid = { version: 1, enabled: true, bundle: { platform: process.platform, cliExecutable: join(root, "ocx") } }; + for (const value of ["{", "[]", "null", "42", JSON.stringify({ ...valid, enabled: "yes" }), JSON.stringify({ ...valid, bundle: null }), JSON.stringify({ ...valid, version: 2 }), JSON.stringify({ ...valid, pending: {} }), JSON.stringify({ ...valid, bundle: { platform: "other", cliExecutable: "relative" } }), JSON.stringify({ ...valid, bundle: { platform: process.platform, cliExecutable: "relative" } }), JSON.stringify({ ...valid, bundle: { platform: process.platform, cliExecutable: "/" + "x".repeat(32768) } }), JSON.stringify({ ...valid, bundle: { platform: process.platform, cliExecutable: "/fixture/line\nbreak" } })]) { + writeFileSync(recordPath, value); expect(readDesktopCliRecord({ recordPath }).state).toBe("invalid"); + } + writeFileSync(recordPath, " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES)); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ issue: "record-invalid" }); + writeFileSync(recordPath, " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES + 1)); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ issue: "record-too-large" }); + rmSync(recordPath); mkdirSync(recordPath); + expect(readDesktopCliRecord({ recordPath }).state).toBe("invalid"); + }); + test("descriptor sentinel bounds growth after metadata and strict UTF-8 rejects corrupt bytes", () => { + const root = box(); const recordPath = join(root, "cli.json"); + writeFileSync(recordPath, Buffer.from([0xff])); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ issue: "record-invalid" }); + writeFileSync(recordPath, " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES + 1)); + const script = `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; +const original = fs.fstatSync; fs.fstatSync = (...args) => { const info = original(...args); info.size = 1; return info; }; syncBuiltinESMExports(); +const mod = await import(${JSON.stringify(pathToFileURL(repoPath("src", "lib", "desktop-cli-record.mjs")).href)}); +console.log(JSON.stringify(mod.readDesktopCliRecord({recordPath:${JSON.stringify(recordPath)}})));`; + const result = spawnSync("node", ["--input-type=module", "-e", script], { encoding: "utf8", timeout: 5000 }); + expect(result.status).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ issue: "record-too-large" }); + }); + (process.platform === "win32" ? test.skip : test)("reader open failure is unreadable and disabled intent wins over pending journal", () => { + const root = box(); const recordPath = join(root, "cli.json"); + symlinkSync(recordPath, recordPath); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ state: "unreadable", issue: "record-unreadable" }); + rmSync(recordPath); writeFileSync(recordPath, '{"version":1,"enabled":false,"pending":{}}'); + expect(readDesktopCliRecord({ recordPath }).state).toBe("disabled"); + }); + test("record absence and disabled intent keep the package path; invalid records error", () => { + for (const state of ["missing", "disabled"] as const) expect(planDesktopCliHandoff({ argv: ["status"], env: {}, recordRead: { state, path: "fixture" } })).toEqual({ kind: "continue", reason: state }); + for (const issue of ["record-invalid", "record-too-large", "record-pending", "record-unreadable"] as const) expect(planDesktopCliHandoff({ argv: ["status"], env: {}, recordRead: { state: issue === "record-unreadable" ? "unreadable" : "invalid", issue, path: "fixture" } })).toEqual({ kind: "error", issue }); + }); + test("update, removal, internal inspection and codex-cli-update are exceptions", () => { + for (const argv of [["update"], ["update", "--help"], ["uninstall"], ["remove"], ["__update-badge"], ["system", "codex-cli-update", "malformed"], [PREFIX + "invalid", "update"]]) { + expect(desktopHandoffExcluded(argv, {})).toBe(true); + expect(planDesktopCliHandoff({ argv, env: {}, recordRead: ready() }, { stat: (() => { throw new Error("must not read target"); }) as typeof stat })).toMatchObject({ reason: "excluded" }); + } + expect(desktopHandoffExcluded(["--version"], {})).toBe(false); + expect(desktopHandoffExcluded(["inspect", "config"], {})).toBe(false); + }); + test("OCX_NO_DESKTOP_HANDOFF is exact and keeps user version handoff eligible", () => { + expect(desktopHandoffExcluded(["--version"], { OCX_NO_DESKTOP_HANDOFF: "1" })).toBe(true); + expect(desktopHandoffExcluded(["--version"], { OCX_NO_DESKTOP_HANDOFF: "0" })).toBe(false); + }); + test("absolute regular executable target selected; self realpath never delegated", () => { + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths: ["/fixture/package/ocx.mjs"] }, deps)).toEqual({ kind: "handoff", target: "/fixture/desktop/ocx" }); + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths: ["/fixture/alias"] }, { ...deps, realpath: () => "/fixture/same" })).toMatchObject({ kind: "error", issue: "target-self" }); + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready("relative") }, deps)).toMatchObject({ issue: "target-invalid" }); + const selfPaths = ["/fixture/missing-self"]; + const missingSelf = { ...deps, realpath: (path: string) => { if (path === selfPaths[0]) throw coded("ENOENT"); return path; } }; + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths }, missingSelf).kind).toBe("handoff"); + const badSelf = { ...deps, realpath: (path: string) => { if (path === selfPaths[0]) throw coded("ELOOP"); return path; } }; + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths }, badSelf)).toMatchObject({ issue: "target-unusable" }); + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready() }, { ...deps, stat: (() => ({ isFile: () => false })) as unknown as typeof statSync })).toMatchObject({ issue: "target-invalid" }); + }); + test("target ENOENT resumes and EACCES or other errors fail", () => { + for (const code of ["ENOENT", "EACCES", "ENOTDIR", "ENOEXEC", "ELOOP"]) { + const plan = planDesktopCliHandoff({ argv: ["status"], env: {}, platform: "linux", recordRead: ready() }, { ...deps, access: () => { throw coded(code); } }); + expect(plan.kind).toBe(code === "ENOENT" ? "continue" : "error"); + } + }); + test("Windows realpath comparison is case insensitive and defers execute permission to spawn", () => { + let mode = -1; + const plan = planDesktopCliHandoff({ argv: [], env: {}, platform: "win32", recordRead: ready("C:\\App\\ocx.exe"), selfPaths: ["c:\\app\\OCX.exe"] }, { ...deps, access: (_path, value) => { mode = value ?? -1; } }); + expect(plan).toMatchObject({ issue: "target-self" }); expect(mode).toBe(0); + }); +}); + +describe("async handoff transport", () => { + test("argv, inherited stdin, fresh proof and exit code; signal handlers cleaned", async () => { + const fake = fakeSpawn(); + const result = await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { + argv: [PREFIX + "obsolete", "claude", "a b", "--literal=$x"], proof, context, + env: { PATH: "fixture", OCX_BUN_RUNTIME_PATH: "stale", OCX_BUN_RUNTIME_SOURCE: "bundled" }, + }, { spawn: fake.run, parent: fake.parent as unknown as NodeJS.Process, platform: "linux" }); + expect(result).toEqual({ kind: "exit", code: 17, signal: null }); + const called = fake.observed()!; + expect(called.argv).toEqual([PREFIX + proof, "claude", "a b", "--literal=$x"]); + expect(called.options).toMatchObject({ stdio: "inherit", shell: false, windowsHide: true }); + expect(called.options.env).toEqual({ PATH: "fixture", OCX_NO_DESKTOP_HANDOFF: "1", OCX_NODE_LAUNCH_CONTEXT: context }); + expect(fake.killed).toEqual(["SIGTERM"]); expect(fake.parent.listenerCount("SIGTERM")).toBe(0); + }); + test("child signal is retained and asynchronous spawn ENOENT alone resumes", async () => { + for (const error of [undefined, "ENOENT", "EACCES", "ENOEXEC"]) { + const fake = fakeSpawn(error, "SIGTERM"); + const result = await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { argv: [], env: {}, proof, context }, { spawn: fake.run, parent: fake.parent as unknown as NodeJS.Process }); + expect(result.kind).toBe(!error ? "exit" : error === "ENOENT" ? "continue" : "error"); + if (result.kind === "exit") expect(result.signal).toBe("SIGTERM"); + expect(fake.parent.eventNames()).toHaveLength(0); + } + }); + test("Windows forwards INT/TERM only and a kill race still settles from child exit", async () => { + const fake = fakeSpawn(); + const pending = runDesktopCliHandoff({ kind: "handoff", target: "C:\\App\\ocx.exe" }, { argv: [], env: {}, proof, context }, { spawn: fake.run, parent: fake.parent as unknown as NodeJS.Process, platform: "win32" }); + expect(fake.parent.listenerCount("SIGHUP")).toBe(0); expect(fake.parent.listenerCount("SIGINT")).toBe(1); + expect((await pending).kind).toBe("exit"); + const parent = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { kill: () => never }; + child.kill = () => { throw new Error("already exited"); }; + const run = (() => { queueMicrotask(() => { parent.emit("SIGTERM"); child.emit("exit", null, null); }); return child; }) as unknown as typeof spawn; + expect(await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { argv: [], env: {}, proof, context }, { spawn: run, parent: parent as unknown as NodeJS.Process })).toEqual({ kind: "exit", code: 1, signal: null }); + }); + test("synchronous spawn errors have the same ENOENT-only policy", async () => { + for (const code of ["ENOENT", "EACCES"]) { + const fail = (() => { throw coded(code); }) as unknown as typeof spawn; + expect((await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { argv: [], env: {}, proof, context }, { spawn: fail })).kind).toBe(code === "ENOENT" ? "continue" : "error"); + } + }); + test("proof reaches the real Claude native-env consumer and keeps exported slots", () => { + const env = { OCX_NODE_LAUNCH_CONTEXT: context, ANTHROPIC_API_KEY: "fixture-user-key", ANTHROPIC_BASE_URL: "https://fixture.invalid", OCX_PRE_BUN_ANTHROPIC_ENV: "obsolete" }; + const argv = ["bun", "index.ts", PREFIX + proof, "claude"]; + expect(initializeNodeLauncherContext(argv, env)?.anthropicEnvSlots).toEqual(["ANTHROPIC_API_KEY"]); + expect(argv).toEqual(["bun", "index.ts", "claude"]); + const config = { port: 10100, providers: {} } as Parameters[0]; + const child = buildNativeClaudeEnv(config, env); + expect(child.ANTHROPIC_API_KEY).toBe("fixture-user-key"); + expect(child.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(child.OCX_NODE_LAUNCH_CONTEXT).toBeUndefined(); + }); + test("internal managing CLI probes set the bypass in the actual spawn environment", () => { + let actual: NodeJS.ProcessEnv | undefined; + const probe = ((_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { actual = options.env; return { status: 0, stdout: "2.82.0", stderr: "" }; }) as unknown as typeof spawnSync; + const result = observeManagingClis(null, { platform: "linux", env: { PATH: "/fixture", KEEP: "yes" }, execPath: "/fixture/self", exists: path => path === "/fixture/ocx", isFile: () => true, spawn: probe }); + expect(result.path.status).toBe("observed"); expect(actual).toEqual({ PATH: "/fixture", KEEP: "yes", OCX_NO_DESKTOP_HANDOFF: "1" }); + }); +}); + +function packageFixture() { + const home = box(); const directory = join(home, ".opencodex-desktop"); mkdirSync(directory); + const target = join(home, "desktop-cli"); + const recordPath = join(directory, "cli.json"); + writeFileSync(recordPath, JSON.stringify({ version: 1, enabled: true, bundle: { platform: process.platform, cliExecutable: target } })); + const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, USERPROFILE: home, OCX_NO_DESKTOP_HANDOFF: "0", OCX_NODE_LAUNCH_CONTEXT: "forged", ANTHROPIC_API_KEY: "fixture-user-key", OPENCODEX_BUN_PATH: join(home, "missing-bun") }; + return { home, target, env }; +} +const posixTest = process.platform === "win32" ? test.skip : test; +const launcher = repoPath("bin", "ocx.mjs"); +posixTest("real Node launcher regenerates proof, forwards argv/stdin, mirrors exit and bypasses Bun lookup", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, `#!/usr/bin/env node\nconst fs = require('node:fs');\nconsole.log(JSON.stringify({ argv: process.argv.slice(2), context: JSON.parse(process.env.OCX_NODE_LAUNCH_CONTEXT), stdin: fs.readFileSync(0, 'utf8'), bypass: process.env.OCX_NO_DESKTOP_HANDOFF }));\nprocess.exit(23);\n`); + chmodSync(fixture.target, 0o755); + const result = spawnSync("node", [launcher, "claude", "a b", PREFIX + "old"], { env: fixture.env, input: "fixture stdin\n", encoding: "utf8", timeout: 5000 }); + expect(result.error).toBeUndefined(); expect(result.status).toBe(23); + const row = JSON.parse(result.stdout.trim()); + expect(row.stdin).toBe("fixture stdin\n"); expect(row.bypass).toBe("1"); + expect(row.argv.slice(1)).toEqual(["claude", "a b"]); + expect(row.argv[0]).toBe(PREFIX + row.context.proof); + expect(row.context.proof).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(row.context.anthropicEnvSlots).toContain("ANTHROPIC_API_KEY"); + const argv = ["bun", "index.ts", ...row.argv]; + const captured = initializeNodeLauncherContext(argv, { OCX_NODE_LAUNCH_CONTEXT: JSON.stringify(row.context) }); + expect(captured?.anthropicEnvSlots).toContain("ANTHROPIC_API_KEY"); +}); +posixTest("real target disappearance resumes Bun; EACCES never resumes Bun", () => { + const fixture = packageFixture(); + const absent = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(absent.stderr).toContain("OPENCODEX_BUN_PATH"); + writeFileSync(fixture.target, "not executable"); chmodSync(fixture.target, 0o600); + const denied = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(denied.status).toBe(1); expect(denied.stderr).toContain("target-unusable"); expect(denied.stderr).not.toContain("OPENCODEX_BUN_PATH"); +}); +posixTest("real update-help is untouched; opt-out and bad record activate distinct paths", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!/usr/bin/env node\nconsole.log('desktop-picked'); process.exit(0);\n"); chmodSync(fixture.target, 0o755); + const help = spawnSync("node", [launcher, "update", "--help"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(help.status).toBe(0); expect(help.stdout).toContain("Usage: ocx update"); expect(help.stdout).not.toContain("desktop-picked"); + const skipped = spawnSync("node", [launcher, "--version"], { env: { ...fixture.env, OCX_NO_DESKTOP_HANDOFF: "1" }, encoding: "utf8", timeout: 5000 }); + expect(skipped.stdout).not.toContain("desktop-picked"); expect(skipped.stderr).toContain("OPENCODEX_BUN_PATH"); + writeFileSync(join(fixture.home, ".opencodex-desktop", "cli.json"), "{"); + const invalid = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(invalid.status).toBe(1); expect(invalid.stderr).toContain("record-invalid"); expect(invalid.stderr).not.toContain("OPENCODEX_BUN_PATH"); +}); +posixTest("real child terminating signal is mirrored by Node parent", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!/usr/bin/env node\nprocess.kill(process.pid, 'SIGTERM');\n"); chmodSync(fixture.target, 0o755); + const result = spawnSync("node", [launcher, "status"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(result.signal).toBe("SIGTERM"); +}); +posixTest("signal sent only to real launcher reaches its child and waits for child exit", async () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(29));\nconsole.log(process.pid); setInterval(() => {}, 1000);\n"); chmodSync(fixture.target, 0o755); + const child: ChildProcess = spawn("node", [launcher, "status"], { env: fixture.env, stdio: ["ignore", "pipe", "pipe"] }); + let timer: ReturnType | undefined; + let targetPid: number | undefined; + try { + const code = await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error("fixture timeout")), 5000); + child.once("error", reject); child.once("exit", resolve); + child.stdout!.once("data", bytes => { targetPid = Number(String(bytes).trim()); child.kill("SIGTERM"); }); + }); + expect(code).toBe(29); + } finally { + clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) { + if (targetPid && Number.isSafeInteger(targetPid)) { try { process.kill(targetPid, "SIGKILL"); } catch { /* exited */ } } + child.kill("SIGKILL"); + } + } +}); +test("launcher source anchors preserve exceptions before handoff and handoff before repair/resolver", () => { + const source = readFileSync(launcher, "utf8"); + const hook = source.indexOf("const desktopPlan ="); + expect(hook).toBeGreaterThan(source.indexOf("const codexCliUpdateInspection =")); + expect(hook).toBeLessThan(source.indexOf('if (process.argv[2] === "update" && installMethod === "mise")')); + expect(hook).toBeLessThan(source.indexOf("const probe = bootRestoreProbe(")); + expect(hook).toBeLessThan(source.indexOf("const bunRuntime = resolveBun(")); + expect(source).toContain("const { launchProof, launchContext, inheritedEnv } = createNodeLaunchContext();"); + expect(source.match(/randomBytes\(32\)/g)).toHaveLength(1); +}); +``` + +### NEW `tests/cli/cli-path-diagnostics.test.ts` (complete) + +```ts +import { describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { collectCliPathDiagnostics, formatCliCommandLine, formatCliStatusHealthLabel, cliCommandDoctorChecks, type CliPathDiagnosticOptions, type CliPathObservation } from "../../src/cli/cli-path-diagnostics"; +import type { DesktopCliRecordRead } from "../../src/lib/desktop-cli-record.mjs"; +import { repoPath } from "../helpers/repo-root"; + +function record(platform: NodeJS.Platform, target: string): DesktopCliRecordRead { + return { state: "ready", path: "fixture", record: { version: 1, enabled: true, bundle: { platform, cliExecutable: target } } }; +} +function fixture(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, files: string[], extra: Partial = {}) { + const windows = platform === "win32"; + const normalize = (path: string) => windows ? path.toLowerCase() : path; + const present = new Set(files.map(normalize)); + let observations = 0; + const observe = (path: string): CliPathObservation => { observations++; return present.has(normalize(path)) ? { kind: "usable", realpath: path } : { kind: "missing" }; }; + const result = collectCliPathDiagnostics({ platform, env, cwd: windows ? "C:\\work" : "/work", home: windows ? "C:\\Users\\example" : "/Users/example", recordRead: record(platform, windows ? "C:\\App\\ocx.exe" : "/Applications/OpenCodex.app/Contents/MacOS/ocx"), observe, ...extra }); + return { result, observations }; +} +const desktopBin = "/Users/example" + "/.opencodex-desktop/bin"; +const shim = desktopBin + "/ocx"; +const target = "/Applications/OpenCodex.app/Contents/MacOS/ocx"; +describe("observed ocx PATH selection", () => { + test("POSIX Desktop shim first, npm later; never executes candidates", () => { + const { result } = fixture("darwin", { PATH: `${desktopBin}:/npm/bin` }, [shim, target, "/npm/bin/ocx"]); + expect(result.pathFirst?.path).toBe(shim); expect(result.expectedExecutable).toBe(shim); + expect(result.desktopFirstOnPath).toBe(true); expect(result.shellResolution).toBe("unobserved"); expect(result.issues).toEqual([]); + const source = readFileSync(repoPath("src", "cli", "cli-path-diagnostics.ts"), "utf8"); + expect(source).not.toMatch(/child_process|Bun\.spawn|execSync|spawnSync/); + }); + test("npm first is an issue even if that launcher can hand off", () => { + const { result } = fixture("linux", { PATH: `/npm/bin:${desktopBin}` }, ["/npm/bin/ocx", shim, target]); + expect(result.pathFirst?.path).toBe("/npm/bin/ocx"); expect(result.desktopFirstOnPath).toBe(false); expect(result.issues).toContain("path-first-not-desktop"); + expect(cliCommandDoctorChecks(result)[0]!.level).toBe("WARN"); + }); + test("POSIX empty and relative PATH elements resolve against cwd; absent PATH differs", () => { + for (const path of [":/bin", ".:/bin", "tools:/bin"]) { + const { result } = fixture("linux", { PATH: path }, ["/work/ocx", "/work/tools/ocx", "/bin/ocx", shim, target]); + expect(result.pathFirst?.path).toBe(path.startsWith("tools") ? "/work/tools/ocx" : "/work/ocx"); + } + expect(fixture("linux", {}, ["/work/ocx", shim, target]).result.pathFirst).toBeNull(); + expect(fixture("linux", { PATH: "" }, ["/work/ocx", shim, target]).result.pathFirst?.path).toBe("/work/ocx"); + }); + test("Windows PATHEXT order, case-insensitive Path, quotes and bundled expected exe", () => { + const { result } = fixture("win32", { Path: '"C:\\App";C:\\npm', PathExt: ".CMD;.EXE" }, ["C:\\App\\ocx.exe", "C:\\App\\ocx.cmd", "C:\\npm\\ocx.cmd"]); + expect(result.pathFirst?.extension).toBe(".cmd"); expect(result.expectedExecutable).toBe("C:\\App\\ocx.exe"); expect(result.issues).toContain("path-first-not-desktop"); + const selected = fixture("win32", { PATH: "c:\\app", PATHEXT: ".EXE;.CMD" }, ["C:\\App\\ocx.exe"]).result; + expect(selected.desktopFirstOnPath).toBe(true); + }); + test("Windows empty entry and cmd cwd candidate remain distinct from PATH first", () => { + const row = fixture("win32", { PATH: "C:\\App", PATHEXT: ".EXE" }, ["C:\\App\\ocx.exe", "C:\\work\\ocx.exe"]).result; + expect(row.pathFirst?.path).toBe("C:\\App\\ocx.exe"); expect(row.currentDirectoryCandidate?.path).toBe("C:\\work\\ocx.exe"); expect(row.issues).toContain("windows-current-directory-shadow"); + expect(fixture("win32", { PATH: ";C:\\App", PATHEXT: ".EXE" }, ["C:\\work\\ocx.exe", "C:\\App\\ocx.exe"]).result.pathFirst?.path).toBe("C:\\work\\ocx.exe"); + }); + test("invalid PATHEXT entries ignored; extensionless last; missing PATH and bounded scan", () => { + const row = fixture("win32", { PATH: "C:\\npm", PATHEXT: ".EXE;bad;../evil;.CMD" }, ["C:\\npm\\ocx", "C:\\App\\ocx.exe"]).result; + expect(row.pathFirst?.extension).toBe(""); + expect(fixture("linux", {}, [shim, target]).result.issues).toContain("path-missing"); + const long = fixture("linux", { PATH: Array(257).fill("/missing").join(":") }, [shim, target]); + expect(long.result.issues).toContain("path-scan-truncated"); expect(long.observations).toBe(258); expect(long.result.desktopFirstOnPath).toBeNull(); + const extensions = Array.from({ length: 33 }, (_, i) => `.x${i}`).join(";"); + expect(fixture("win32", { PATH: "C:\\App", PATHEXT: extensions }, ["C:\\App\\ocx.exe"]).result.issues).toContain("path-scan-truncated"); + }); + test("missing or disabled Desktop is informational; bad records and missing target fail", () => { + for (const state of ["missing", "disabled"] as const) { + const row = fixture("linux", { PATH: "/npm" }, ["/npm/ocx"], { recordRead: { state, path: "fixture" } }).result; + expect(row.desktopFirstOnPath).toBeNull(); expect(cliCommandDoctorChecks(row)[0]!.level).toBe("OK"); + } + for (const issue of ["record-invalid", "record-too-large", "record-pending", "record-unreadable"] as const) { + const row = fixture("linux", {}, [], { recordRead: { state: issue === "record-unreadable" ? "unreadable" : "invalid", path: "fixture", issue } }).result; + expect(row.issues).toContain(issue); expect(cliCommandDoctorChecks(row)[0]!.level).toBe("FAIL"); + } + const missing = fixture("linux", { PATH: "/npm" }, ["/npm/ocx"]).result; + expect(missing.issues).toContain("desktop-target-missing"); expect(missing.issues).toContain("expected-command-missing"); + }); + test("unusable/unreadable observations have named codes and no false OK", () => { + const row = fixture("linux", { PATH: "/bad" }, [], { observe: path => path === target ? { kind: "unusable" } : path === shim ? { kind: "unusable" } : { kind: "unreadable" } }).result; + expect(row.issues).toEqual(expect.arrayContaining(["desktop-target-unusable", "expected-command-unusable", "path-unreadable"])); + expect(row.desktopFirstOnPath).toBeNull(); + expect(cliCommandDoctorChecks(row)[0]!.level).toBe("FAIL"); + }); + test("realpath alias of owned command compares physically and output stays one safe line", () => { + const row = fixture("linux", { PATH: "/alias" }, [], { observe: path => ({ kind: "usable", realpath: path === target ? target : shim }) }).result; + expect(row.desktopFirstOnPath).toBe(true); + const line = formatCliCommandLine(row); expect(line).toContain("shell=unobserved"); expect(line).not.toContain("\n"); + const unsafe = { ...row, pathFirst: { ...row.pathFirst!, path: "/fixture/line\nbreak/ocx" } }; + expect(formatCliCommandLine(unsafe)).not.toContain("\n"); + }); + test("connected status adapter renders one command line and preserves local labels", () => { + const row = fixture("linux", { PATH: "/npm" }, ["/npm/ocx", shim, target]).result; + for (const connected of [false, true]) { + const rendered = `Health: ${formatCliStatusHealthLabel("healthy", row, connected)}${connected ? " (local)" : ""}`; + const lines = rendered.split("\n"); expect(lines).toHaveLength(2); + for (const line of lines) expect(line.includes(" (local)")).toBe(connected); + expect(lines[1]).toContain("ocx command:"); + } + const json = JSON.parse(JSON.stringify({ proxy: { health: { message: "healthy" } }, cliCommand: row })); + expect(json.proxy.health.message).toBe("healthy"); + }); + (process.platform === "win32" ? test.skip : test)("default observer rejects directory/non-executable and observes physical symlink without execution", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-observe-")); + try { + const bin = join(root, ".opencodex-desktop", "bin"); mkdirSync(bin, { recursive: true }); + const target = join(root, "ocx-target"); writeFileSync(target, "fixture; never executed"); chmodSync(target, 0o755); + const shimPath = join(bin, "ocx"); symlinkSync(target, shimPath); + const platform = process.platform; + const options = { platform, home: root, cwd: root, recordRead: record(platform, target), env: { PATH: bin } }; + expect(collectCliPathDiagnostics(options).desktopFirstOnPath).toBe(true); + rmSync(shimPath); mkdirSync(shimPath); + expect(collectCliPathDiagnostics(options).issues).toContain("expected-command-unusable"); + rmSync(shimPath, { recursive: true }); writeFileSync(shimPath, "fixture"); chmodSync(shimPath, 0o600); + expect(collectCliPathDiagnostics(options).issues).toContain("expected-command-unusable"); + expect(collectCliPathDiagnostics({ ...options, env: { PATH: target } }).pathFirst).toBeNull(); + rmSync(shimPath); symlinkSync(shimPath, shimPath); + expect(collectCliPathDiagnostics(options).issues).toContain("path-unreadable"); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + test("status JSON field and human line, doctor independent registration, root dispatcher untouched", () => { + const status = readFileSync(repoPath("src", "cli", "status.ts"), "utf8"); + expect(status).toContain("cliCommand: CliPathDiagnostics;"); expect(status).toContain(" cliCommand,"); + expect(status).toContain("formatCliStatusHealthLabel(health.label, cliCommand, remoteHub.connected)"); + const doctor = readFileSync(repoPath("src", "cli", "doctor.ts"), "utf8"); + expect(doctor).toContain('console.log("\\nocx command selection")'); expect(doctor).toContain('if (check.level === "FAIL") recordDoctorFailure()'); + expect(doctor.indexOf('console.log("\\nocx command selection")')).toBeLessThan(doctor.indexOf('console.log("\\nCodex runtime selection")')); + const json = JSON.parse(JSON.stringify(fixture("linux", { PATH: "/npm" }, ["/npm/ocx", shim, target]).result)); + expect(json.pathFirst.path).toBe("/npm/ocx"); expect(json.shellResolution).toBe("unobserved"); + }); +}); +``` + +## Conditional activation and evidence (C-ACTIVATION-GROUNDING-01) + +These are **planned test names**, not passing results. Read this table together with the complete files above; a guard without its activating fixture is incomplete implementation proof. Each test is scoped to an owned temp directory or injected IO. No test installs Desktop, reads a real home record, performs npm update, or executes discovered PATH candidates. + +| Conditional path | Reachable scenario / observable result | Test name in the new files | +| --- | --- | --- | +| POSIX/Windows record home; reader injection | Home `/Users/example`, Windows USERPROFILE fixture, explicit temp recordPath; exact fixed file path | `fixed record location and injected reader path` | +| Missing record | File absent; `missing`, package plan continues | `fixed record location and injected reader path`; `record absence and disabled intent keep the package path; invalid records error` | +| Disabled intent, including pending journal | Version-1 false tombstone without bundle; no target read or execution | `reader open failure is unreadable and disabled intent wins over pending journal` | +| Reader rejects directory / metadata over limit | Directory or 65537-byte record; invalid/too-large without full-file allocation | `reader refuses oversized, malformed, pending and wrong-platform records` | +| Read-loop EOF and sentinel | Small valid record ends at EOF; descriptor's mocked metadata says size 1 but actual bytes exceed the cap; sentinel triggers too-large | `fixed record location and injected reader path`; `descriptor sentinel bounds growth after metadata and strict UTF-8 rejects corrupt bytes` | +| JSON/UTF-8 parse failure; invalid shape/version/boolean | `{`, array, null, number, unknown version, string enabled, bad UTF-8 | `reader refuses oversized, malformed, pending and wrong-platform records`; `descriptor sentinel bounds growth after metadata and strict UTF-8 rejects corrupt bytes` | +| Pending enabled record | Non-null `pending`; `record-pending`, no target chosen | `reader refuses oversized, malformed, pending and wrong-platform records` | +| Invalid bundle/platform/path/boundary | Null bundle, wrong platform, relative path, path over 32768 characters, newline path | `reader refuses oversized, malformed, pending and wrong-platform records` | +| Open fails other than ENOENT | Self-loop record symlink gives ELOOP, classified unreadable; descriptor cleanup applies to opened records only | `reader open failure is unreadable and disabled intent wins over pending journal` | +| Update-help short circuit | Valid Desktop fixture but `update --help` emits usage/0, no Desktop target output | `real update-help is untouched; opt-out and bad record activate distinct paths` | +| Update/uninstall/remove/internal namespace exceptions | Effective argv strips old proof before classifying; target IO trap never reached | `update, removal, internal inspection and codex-cli-update are exceptions` | +| Inspection under Bun existing guard | Existing `codexCliUpdateInspection && process.versions.bun` guard remains before hook | `launcher source anchors preserve exceptions before handoff and handoff before repair/resolver`; existing `tests/cli/ocx-launcher-source.test.ts` retained for hosted CI | +| Exact bypass and ordinary version | Bypass equals `1` only; ordinary `--version` eligible | `OCX_NO_DESKTOP_HANDOFF is exact and keeps user version handoff eligible`; real exception test | +| No/disabled record versus invalid/read error | Missing/disabled continues; each record issue is a fixed-code error | `record absence and disabled intent keep the package path; invalid records error` | +| Target relative/non-file rejection | Injected relative path or directory; target-invalid, zero spawn | `absolute regular executable target selected; self realpath never delegated` | +| POSIX X_OK / Windows existence check | EACCES fails; Windows mode is F_OK and process creation supplies final executable verdict | `target ENOENT resumes and EACCES or other errors fail`; `Windows realpath comparison is case insensitive and defers execute permission to spawn` | +| Physical self detection / case folding | Launcher alias and recorded target resolve to same path; Windows case differences also reject | `absolute regular executable target selected; self realpath never delegated`; Windows comparison test | +| Self path missing versus other realpath error | A vanished self module is skipped; ELOOP/EACCES reading a present identity fails closed | `absolute regular executable target selected; self realpath never delegated` | +| Target ENOENT / EACCES / ENOTDIR / ELOOP | Actual target absence continues; other IO failures error | `target ENOENT resumes and EACCES or other errors fail`; `real target disappearance resumes Bun; EACCES never resumes Bun` | +| Valid handoff before resolver/boot repair | Invalid Bun override would emit a warning on package path; successful Desktop target has no such warning and returns 23 | `real Node launcher regenerates proof, forwards argv/stdin, mirrors exit and bypasses Bun lookup`; launcher anchor test | +| Fresh proof, old argv-proof removal | Forged inherited context plus an old argv proof; child receives one new proof matching JSON | Real Node launcher test; `argv, inherited stdin, fresh proof and exit code; signal handlers cleaned` | +| Claude exported slot preservation / pollution deletion | API key is proof-listed; BASE_URL is not; real native-env builder preserves one and removes the other | `proof reaches the real Claude native-env consumer and keeps exported slots` | +| Spawn ENOENT versus all other errors | Sync throw or asynchronous error; ENOENT continues, EACCES/ENOEXEC fail | `child signal is retained and asynchronous spawn ENOENT alone resumes`; `synchronous spawn errors have the same ENOENT-only policy` | +| Error then late exit; settle once | Fake child emits error then exit 99; original error result wins, parent listeners removed | `child signal is retained and asynchronous spawn ENOENT alone resumes` | +| Parent signal forwarding and child termination | SIGTERM reaches child when only parent was signaled; parent waits and exits 29; child signal re-emitted by parent | `signal sent only to real launcher reaches its child and waits for child exit`; `real child terminating signal is mirrored by Node parent` | +| Platform signal set / kill race / null code | Windows has no HUP handler; throwing kill does not change completion; null code maps to 1 | `Windows forwards INT/TERM only and a kill race still settles from child exit` | +| Internal version probe env | Selected executable reports its own version; bypass set after inherited fixture env | `internal managing CLI probes set the bypass in the actual spawn environment` | +| POSIX empty/relative/unset PATH | `:`, `.`, `tools`, empty string, absent PATH; cwd candidates only when represented | `POSIX empty and relative PATH elements resolve against cwd; absent PATH differs` | +| Windows PATH names, quoted directories, PATHEXT, extensionless | Case-insensitive env keys and comparison; declared .CMD before .EXE; invalid extensions ignored | `Windows PATHEXT order, case-insensitive Path, quotes and bundled expected exe`; `invalid PATHEXT entries ignored; extensionless last; missing PATH and bounded scan` | +| cmd cwd versus PATH observation | cwd executable plus Desktop PATH candidate; distinct fields and cwd-shadow issue | `Windows empty entry and cmd cwd candidate remain distinct from PATH first` | +| Record states and target/shim observations | Missing/disabled informational; invalid record, missing or unusable target/shim FAIL | `missing or disabled Desktop is informational; bad records and missing target fail`; `unusable/unreadable observations have named codes and no false OK` | +| npm is PATH first | npm + owned shim present; path-first-not-desktop WARN, even if npm would hand off | `npm first is an issue even if that launcher can hand off` | +| Scan truncation/read failure | 257 PATH entries, 33 valid extensions, unreadable candidate; bounded work and null desktopFirstOnPath | `invalid PATHEXT entries ignored; extensionless last; missing PATH and bounded scan`; unusable/unreadable test | +| Physical alias and output sanitation | Alias realpath matches owned command; control character path renders on one safe line | `realpath alias of owned command compares physically and output stays one safe line` | +| Doctor OK/WARN/FAIL / additive status DTO | Valid Desktop first OK; ordering conflict WARN; invalid record/target FAIL contributes to doctorFailed; serialization preserves issue codes | The fixture tests above; `status JSON field and human line, doctor independent registration, root dispatcher untouched` | +| Connected status local labeling | Existing index appends `(local)` to the multiline view's last line; healthLabel explicitly preserves its first-line local label | `connected status adapter renders one command line and preserves local labels` (both connected values, simulated unchanged root rendering). | + +The growth fixture patches `fstatSync` only inside a disposable **Node child**, followed by `syncBuiltinESMExports`, to deterministically simulate growth between metadata and read. It never monkey-patches the shared Bun test process. Windows native execute-format and signal handling remain 030 native evidence, not a claimed result of the injected Windows policy fixture. + +## Field and enum lifecycle (PLAN-FIELD-CHAIN-01) + +| Field / values | Creation → serialization → deserialization → every in-repository consumer | +| --- | --- | +| Persistent `version`, `enabled`, `bundle.platform`, `bundle.cliExecutable`; optional `pending` gate | wp1 writer (010) → atomic version-1 `cli.json` → bounded `readDesktopCliRecord` → `planDesktopCliHandoff`, `collectCliPathDiagnostics`. Only settled enabled records expose the narrowed bundle DTO. No wp2 write/serializer. | +| Reader `state`: missing/disabled/ready/invalid/unreadable; `path`; `record`; `issue` | Reader IO/schema branches → process-local DTO (not separately persisted) → no extra parser → handoff plan and diagnostics. Tests enumerate the five states. Record issue values are `record-invalid`, `record-too-large`, `record-pending`, `record-unreadable`; plan maps them to stderr errors, diagnostics maps them to status JSON and doctor FAIL. | +| Plan `kind`, `reason`, `issue`, `target` | `planDesktopCliHandoff` creates continue(reason excluded/missing/disabled/target-missing), error(fixed record/target code), handoff(target realpath) → process-local only → `bin/ocx.mjs` branches → continue/update/Bun, fixed-code exit 1, or transport call. Tests consume every discriminant. | +| Transport result kind continue/error/exit, issue spawn-failed, code, signal | Spawn throw/events + cleanup → process-local promise result → launcher awaits → ENOENT continuation, fixed error exit 1, numeric exit or signal re-emission. Test doubles and real Node subprocesses consume it; no IPC record/JSON form added. | +| Existing launch-context `version`, `proof`, `anthropicEnvSlots`, `codexCliInspectionEnv` | Extracted Node factory snapshots parent env and generates random proof → `JSON.stringify` to OCX_NODE_LAUNCH_CONTEXT + exactly one argv proof → `src/cli/index.ts:209 initializeNodeLauncherContext()` consumes/validates/removes both → `trustedNodeLauncherContext()` → `deleteUntrustedAnthropicSlots` / Claude builders; inspection resolver continues consuming its original proof-bound fields. No schema/version change. | +| `OCX_NO_DESKTOP_HANDOFF=1` | `probeVersionOnce` or handoff child env copy (never mutates parent's env) → subprocess env → next package launcher's exact-1 exception → ordinary package path. Compiled CLI does not use it as ownership evidence. Tests verify generated env, exact value and normal --version eligibility. | +| `CliPathObservation.kind`, `realpath` | Default stat/access/realpath IO or fixture observation → process-local only → collector; real filesystem cases are covered by `default observer rejects directory/non-executable and observes physical symlink without execution`. missing skips candidate; unusable flags target/shim failures; unreadable marks incomplete PATH; usable creates candidates and physical comparison. | +| `CliPathCandidate.path`, `realpath`, `pathIndex`, `extension`; `candidates`, `pathFirst`, `currentDirectoryCandidate` | Observe scan, in PATH then PATHEXT order; cwd marker index -1 kept separate → nested `cliCommand` JSON via handleStatus → external JSON clients may inspect without a new in-repo parser → formatter, doctor checks and fixture tests use path/physical order. All DTO fields are plain values, no functions/undefined. | +| `configured`, `recordState`, `expectedExecutable`, `handoffTarget`, `desktopFirstOnPath`, `shellResolution` | Collector derives intention/expected target from reader and observed order; null firstness means no configured Desktop or incomplete scan; shellResolution is always unobserved → status.json.cliCommand / schemaVersion 1 → root JSON.stringify only (`index.ts:1705–1707`) → external callers + tests; human `formatCliCommandLine` and `cliCommandDoctorChecks` consume the same observation. No runtime/service mutation consumes them. | +| `issues` closed union | Collector adds unique record issue codes, desktop-target-missing/unusable, expected-command-missing/unusable, path-missing, path-first-not-desktop, path-unreadable, path-scan-truncated, windows-current-directory-shadow → status JSON → no local deserializer → formatter lists codes, doctor maps record/target/expected errors to FAIL, other issues to WARN. Fixtures cover all values; no freeform record text becomes a code. | +| `CliStatusJson.cliCommand` | `collectStatus` computes the DTO once and includes it in json → unchanged root JSON serializer → consumers tolerant of additive fields; no new local status parser exists (`rg CliStatusJson` finds its declaration/view only). Human view uses `healthLabel` adapter, raw JSON proxy health fields unchanged. | +| Doctor level OK/WARN/FAIL | `cliCommandDoctorChecks` creates an existing severity shape → not persisted → `runDoctor` prints, FAIL calls existing recordDoctorFailure → existing doctorFailed exit semantics. WARN ordering and unknown shell state do not become FAIL. | + +`formatCliStatusHealthLabel` creates the view-only multiline label from health text, diagnostics and the connected/local flag; status is its only production consumer, while its fixture tests simulate the unchanged root rendering in both flag states. It does not alter or serialize JSON health. + +The issue union is diagnostic, not an executable allowlist. New reader/plan DTOs never enter service ownership, supervision or compatibility claims. In particular `handoffTarget` is not the running proxy binary and `configured` is not “Desktop is currently supervising”. + +## Guard strength and named bypasses (PLAN-BYPASS-NAMED-01) + +Tier notation here identifies **E8 executable checks** versus **E7 documented/operator-followed boundaries**; it does not assert that a host hook recognizes this application logic. All bypassable checks are described as local guards or early warnings. **Final global enforcement layer: none.** + +| Guard / tier | Executing surface | Known bypass path | Residual risk / honest wording | +| --- | --- | --- | --- | +| E8 bounded/schema reader | Reader inside current Node launcher and Bun diagnostics | Same-user process replaces record; older package launchers do not call the reader | Configuration validation and early warning; IDs/hashes are not authenticity or service authority. | +| E8 target validation / self guard | Current package launcher before spawn | Explicit compiled executable, different package launcher, direct source CLI; TOCTOU between validation and process creation | Prevents this invocation's self-delegation and obvious invalid target. Cannot bind binary authenticity or defeat same-user modification. | +| E8 ENOENT-only fallback | Current handoff planner/async child | Bypass flag; old launcher; directly invoke npm package path whose version lacks handoff | Chosen-target errors fail this launch; does not promise every package command is forced to Desktop. | +| E8 exceptions and child recursion opt-out | Current package launcher; managing-cli child env | User sets exact bypass flag or executes update/removal/internal namespace | Explicit escape hatch; does not bypass L1 runtime/service guards. | +| E8 argv proof parser | Existing CLI context parser, fed by extracted plain-Node factory | Same-user caller can directly supply argv/env; calling Node under Bun or bypassing launcher loses the original pre-dotenv capture | Provenance for ordinary Node invocations, not an identity/security barrier against a local attacker. A11 shim review stays mandatory. | +| E8 observe-only tests / repository gates | Named Bun regressions, structure/ratchet/layout gates; parent's hosted CI | Not running tests, untracked files not visible to git-index gates, older-head/skipped CI | Early warning for regressions in the observed tree; no claim of OS shell enforcement or native Windows proof. | +| E7 shell-selection limitation | Public docs, status/doctor wording, native smoke reviewer | Aliases/functions, shell cache, absolute paths, zsh -f, later nvm use, stale parent env, Windows system PATH or cmd cwd | Reports only process PATH observations; shellResolution remains unobserved. | +| E7 staging and review protocol | Parent before structure/privacy/ratchet CI; independent 030 reviewer | Ignoring plan or treating unstaged local gate pass as CI evidence | Requires real current-head evidence; no code-quality/security PASS is granted by this plan. | + +## Goalplan acceptance mapping + +The read-only parent goalplan `opencodex-make-opencodex-desktop-own-the-ocx-com` was inspected without calling any goal/loop/FSM commands. The IDs below preserve its actual criterion identity. Goalplan c-1 says “Linux deb unchanged”; A9 accepts adding the same user shim/rc on deb while keeping `/usr/bin/ocx`. Parent should reconcile that wording in its own goalplan; this worker does not edit it. + +| Criterion | wp2 acceptance / observable evidence | Ownership and completion boundary | +| --- | --- | --- | +| c-1 app-only command after Desktop start | Reader can consume wp1's installed record and diagnostics name the POSIX shim or Windows bundled exe | wp1/030 own actual installation/new-terminal proof. Reader alone does not satisfy c-1. | +| c-2 npm/bun/brew precedence; Windows user PATH | POSIX/Windows fixtures show Desktop-first versus npm-first; cwd/system PATH conflicts do not show false success | wp2 contributes diagnostics only; wp1/030 supply real shell/registry selection evidence. | +| c-3 handoff with recursion guard and escape hatch | Fresh proof and one argv proof, stdin/argv, exit/signal, target self/ENOENT/EACCES, exceptions and exact bypass tests pass | Primary wp2 criterion. Existing package update/Bun/inspection behavior remains available; L1 guards intact. | +| c-4 foreign refusal/idempotence/removal/opt-out | Missing/disabled records continue; invalid/pending refuse, physical self rejects; package removal never hands off | wp2 covers consumers; wp1 owns generated-file ownership, idempotence and cleanup tests. | +| c-5 typecheck/focused tests/structure/privacy/current-head CI | Execute the verifier table after implementation, with dependencies present; no baseline failure reported as passing | wp2 contributes TS/gate evidence. Cargo/native and exact-head required hosted CI belong to 030; current planning typecheck/layout are incomplete. | +| c-6 independent code and security review PASS | Reviewer checks record trust, proof extraction, no supervision marker creation, target failure policy and tested provenance chain | Parent/030 obtain independent reviews. Plan and syntax checks cannot mark c-6 met. | +| c-7 admin squash to dev with docs | These diffs and public text are integrated with 030's en/ko/locale updates and stacked PR retargeting | Parent owns PR/rebase/merge authority; this worker performs none. | + +| Additional acceptance | Proving observation | +| --- | --- | +| No user `--version` blanket exemption | Effective argv `--version` handoffs unless exact bypass is present. Internal probe sets bypass independently. | +| No runtime/service authority widening | No code consumes record IDs as authority; no new OCX_DESKTOP_SUPERVISED marker is stamped; #6809 guard logic unchanged. | +| Root dispatcher untouched | No `src/cli/index.ts` diff; current human renderer still receives its view string and JSON remains stdout-clean. | +| New file scope and docs ownership complete | Both test basenames registered in both rosters; runtime row stays one row/600 lines; source references resolve when staged by parent. | +| Platform claims bounded | POSIX subprocess tests and cross-host Windows policy fixtures distinguished from native Windows execution and new-terminal selection. | + +## Open-PR integration notes (read-only diff inspection) + +`gh pr diff 6807`, `gh pr diff 6809` and `gh pr diff 6802` were actually read. Their states were OPEN at the inspection below; this is a dated planning observation, not future merge evidence. + +| PR / inspected head | Actual overlapping change | Rebase instructions and preserved anchor | +| --- | --- | --- | +| [#6807](https://github.com/lidge-jun/opencodex/pull/6807), `b880f9c6b9efaf161336f0b30ec8d5fcc3cea2b4` | Adds `findDesktopCli`/`findPathBun` import, `pinnedBunVersion`, advisory Desktop guidance in `fail`, and validated PATH Bun fallback in `resolveBun` | Keep its resolver body and advisory discovery unchanged. Merge the new handoff import as an independent import. Handoff is before `const bunRuntime = resolveBun(...)`, so successful handoff never probes/install-selects Bun. Do not turn `findDesktopCli` into the executable target selector. Existing source-oracle substring checks for snapshot constants remain present inside the extracted factory. | +| [#6809](https://github.com/lidge-jun/opencodex/pull/6809), `b3b72189078137a00b32571de861d3b963582f44` | Imports live supervision; adds latches/refusals in `runPackageManagerSelfUpdate`, direct recovery, service refresh, pre-stop, replacement and post-install | Preserve every supervision check and latch. Handoff has update/removal exceptions, so it does not move these commands into another owner's updater. In its inspected head the semantic insertion boundary is after the inspection block around line 999, instead of baseline 963. Use `const codexCliUpdateInspection` and the following mise-update `if` as anchors. The public command record grants no stop/replace/restore authority. | +| [#6802](https://github.com/lidge-jun/opencodex/pull/6802), `1718fcad8ba90e6afd12a8c5a03f7fc222f69742` | `selectStatusStartupHealth` returns `{ startup, startupSource }`; status adds `startupSource` and live supervision; doctor changes restart-safety source and advice | Keep those type/signature/caller changes. Add `cliCommand` as a separate additive field near `versionSkew`; compute command diagnostics at `collectStatus` entry, not by changing startupSource or serviceSummary. Doctor's new section is after Paths and before response-state sections, leaving its restart-safety anchor around 1380 alone. Its supervision `unsupported` is not Windows PATH success. | + +Parent's manual stack: wp2 consumes the final wp1 contract; PR2 initially targets the open wp1 branch, then retargets/rebases to `dev` after PR1 lands. Do not blindly replay baseline line patches over L1. Regenerate these narrow diffs against the integrated source and rerun only checks affected by the rebase, then inspect required CI for the exact new head. Missing, skipped, cancelled, awaiting approval or older-head results are not passing evidence. + +`structure/runtime.md` is also shared with all three L1 PRs. Preserve their text and keep this handoff contract in the existing entrypoint row with **zero line growth**. Public desktop-guide edits belong to 030's coordinated change rather than another competing edit here. + +## File-size and structure budgets + +Read `scripts/file-size-ratchet.ts`, `tests/fixtures/file-size-baseline.json` and `structure/manifest.json`. `THRESHOLD=2000`; an uncapped file at **2000 or more** fails, so its usable ceiling is **1999**, not 2000. A recorded cap is stricter and only decreases via `Math.min` (`scripts/file-size-ratchet.ts:188`). None of the modified files below has a per-file recorded cap at the baseline. Structure's separate `sizeBudgetLines=600` still applies, including runtime at exactly 600. `.d.mts` does not match SCAN_EXTENSIONS and needs a manual small-file budget; do not claim the ratchet covers it. + +Counts below use the ratchet's newline-count convention and the complete proposed code/diffs. They are planning budgets, not post-implementation results. Keep room for reviewer fixes and recalculate against the current integration head. + +| Path | Baseline lines | Proposed lines | Applicable ceiling | Budget rule | +| --- | ---: | ---: | ---: | --- | +| `src/lib/desktop-cli-record.mjs` | NEW | 63 | 1999 | New scanned source/test, no recorded cap | +| `src/lib/desktop-cli-record.d.mts` | NEW | 19 | 120 | Manual declaration ceiling; ratchet does not scan .mts | +| `src/lib/desktop-cli-handoff.mjs` | NEW | 84 | 1999 | New scanned source/test, no recorded cap | +| `src/lib/desktop-cli-handoff.d.mts` | NEW | 20 | 120 | Manual declaration ceiling; ratchet does not scan .mts | +| `src/cli/cli-path-diagnostics.ts` | NEW | 124 | 1999 | New scanned source/test, no recorded cap | +| `tests/cli/ocx-launcher-desktop-handoff.test.ts` | NEW | 276 | 1999 | New scanned source/test, no recorded cap | +| `tests/cli/cli-path-diagnostics.test.ts` | NEW | 129 | 1999 | New scanned source/test, no recorded cap | +| `bin/ocx.mjs` | 1120 | 1151 | 1999 | No recorded cap; keep below 2000 | +| `src/service/managing-cli.ts` | 200 | 201 | 1999 | No recorded cap; keep below 2000 | +| `src/cli/status.ts` | 937 | 941 | 1999 | No recorded cap; keep below 2000 | +| `src/cli/doctor.ts` | 1722 | 1729 | 1999 | No recorded cap; keep below 2000 | +| `scripts/test-layout/layout.json` | 1981 | 1983 | 1999 | No recorded cap; keep below 2000 | +| `tests/fixtures/test-layout-expected.json` | 1179 | 1180 | 1999 | No recorded cap; keep below 2000 | +| `structure/runtime.md` | 600 | 600 | 600 | Replace same number of lines; zero headroom | +| `structure/cli-management.md` | 107 | 111 | 600 | No recorded cap; obey structure 600 | +| `structure/ops/service-and-sidecars.md` | 541 | 543 | 600 | No recorded cap; obey structure 600 | +| `docs-site/src/content/docs/getting-started/installation.md` | 125 | 131 | 1999 | No recorded cap; keep below 2000 | +| `docs-site/src/content/docs/reference/cli.md` | 324 | 326 | 1999 | No recorded cap; keep below 2000 | +| `src/cli/index.ts` | 1976 | 1976 | 1999 | OUT; no modifications | + +The plan itself is under `devlog/`, excluded by the file-size ratchet (`scripts/file-size-ratchet.ts:21–22`) and outside the structure-doc 600-line budget. Its full code/diffs are intentionally not scattered into unassigned companion files. Do not run `ratchet:update` to mask growth. The layout manifest has only 16 lines of projected room before its uncapped maximum; combine entries on existing JSON lines or split tooling under an explicit parent-approved plan if concurrent registrations exhaust it. + +`structure/manifest.json` already maps `bin/`, `src/lib/`, `src/cli/` and `src/service/` to runtime; CLI management maps `src/cli/`; service-and-sidecars documents lib helpers. No new source directory/top-level area or manifest ownership change is required. `structure/desktop-shell.md` and the public desktop guide are reviewed/updated by 010/030 for the installer. Before implementation review, parent also reviews the existing broad map dependents for `src/lib/`/`src/cli/` (overview, config, local-messaging, byte-accounting, responses-wire-shapes, responses-failover, responses-spend, inventory, gui-and-management-api, dashboard-and-usage, clients/integrations, clients/chatgpt-desktop, clients/claude-desktop and ops/docs-and-release). Their unrelated contracts receive no copied handoff prose; update only a changed local consequence. Runtime owns entrypoint ordering, CLI management owns command diagnostics, Desktop-shell owns generated-command installation. + +## Verifier grounding and actual planning-time results + +All shell invocations in this delegated task used the assigned checkout as workdir. Only this document was written. No code/new test files were created or test suites beyond the two layout guard files were run. Baseline checks **do not prove the future changes**. Dependencies were not installed: changing lockfiles or setting up the checkout exceeds this one-document worker scope. + +| Command (run once for the verification observation unless noted) | Observed exit / outcome | Does it read this change target? | +| --- | --- | --- | +| `bun run typecheck` | **1**, TS2688: missing `bun-types`; no implementation typecheck proof | `tsconfig.json` has `include: ["src"]`; after creation it reads the new diagnostics TS/declarations and modified status/doctor/probe. It does not typecheck `.mjs` JS, tests, bin, or prose. Baseline dependency failure means not ready. | +| `bun run structure:check` | **0**, structure/ SSOT checks passed at baseline | `scripts/structure-ssot.ts:301,354,373` reads manifest/docs; after parent's staging it checks new source-path references. It does not read this devlog plan or prove runtime behavior. | +| `bun run privacy:scan` | **0**, Privacy scan passed at baseline | `scripts/privacy-scan.ts:411–414` scans tracked files with gitLsFiles; future tracked source/tests/docs and this plan are included, but this currently untracked plan was not read by that run. No post-plan privacy PASS is claimed. | +| `bun scripts/file-size-ratchet.ts` | **0**, file-size ratchet passed at baseline | `scanRepo → gitLsFiles` (`scripts/file-size-ratchet.ts:133–149`) observes tracked scanned extensions and excludes devlog; after staging it reads source/tests/docs. It does not observe `.d.mts` or this plan. | +| `node --check bin/ocx.mjs` | **0**, baseline syntax accepted | Direct argument reads the existing launcher. It does not follow imports, execute handoff, or parse the proposed diff. Rerun after implementation. | +| `bun scripts/test-with-pinned-bun.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | **1**, Bun 1.4.0, 0 pass / 2 fail; preload cannot find `zod/v4`. The command was repeated once only to retain the explicit exit receipt; same import failure | Direct arguments read both layout guards, whose resolver/oracle read layout.json and expected fixture. They observe the new names only after creation/registration; this baseline could not start assertions. No layout PASS. | +| Node stdin syntax checks for the two proposed `.mjs` code fences | **0** for reader and **0** for handoff | The extraction command below reads this exact document's NEW JS bodies and passes them to `node --check --input-type=module`. It parses the proposed code only; no function/test/import is executed. | +| `bun scripts/test-with-pinned-bun.ts tests/cli/ocx-launcher-desktop-handoff.test.ts tests/cli/cli-path-diagnostics.test.ts` | **NEW 파일 생성 후 실행**; no exit result yet | Direct arguments target both new files and their imports. Requires checkout dependencies. POSIX real-launcher cases skip on Windows, so a green Windows policy fixture is not native signal/terminal proof. | + +Copy-paste syntax-extraction verifier (already run against the two JS bodies, both exit 0; no output files): + +```sh +python3 - <<'PY' +from pathlib import Path +import re, subprocess +text = Path('devlog/_fin/261009_desktop_owned_path_cli/020_launcher_handoff_and_diagnostics.md').read_text() +for match in re.finditer(r'### NEW `([^`]+\.mjs)`[^\n]*\n\n```js\n(.*?)\n```', text, re.S): + result = subprocess.run(['node', '--check', '--input-type=module'], input=match[2], text=True, capture_output=True) + print(match[1], 'exit', result.returncode) + if result.returncode: + raise SystemExit(result.returncode) +PY +``` + +After implementation, execute only the two focused new files, the two layout guards, typecheck and the relevant read-only gates above as the local wp2 minimum. Existing launcher, managing-cli, Claude provenance and Codex inspection suites remain required hosted-CI coverage. The command-local import graph cannot discover source-as-data tests, so retain the explicit source-oracle/layout checks. This plan does not claim that import-connected testing supersedes them. + +030 owns docs-site dependency/build validation, independent code/security review, exact-head required hosted CI and supported native terminal smoke. Those commands and native scenarios belong to its verifier ledger rather than an unrun PASS here. Parent must stage new source/test paths before its structure/ratchet/privacy validation, because index-based coverage deliberately excludes untracked leftovers. No required verification may be replaced with a green baseline or older-head run. + +## Parent handoff: remaining decisions and risks + +1. Reconcile **64 KiB record limit**, settled `pending` semantics and platform/path normalization with 010's final A4 writer. If 010 needs a larger bounded journal, align both writer and reader in the parent plan before implementation. Disabled tombstones remain valid without bundle fields. +2. Preserve the newly extracted Node proof factory as plain-Node pre-dotenv work. A11's POSIX shim proof generator belongs to 010 and independent security review; Windows direct bundled execution keeps its existing stripping behavior. This wp2 package handoff must not silently substitute a Desktop marker for proof. +3. The multiline status health **view** is the narrow adapter needed by the no-index-edit constraint. Its connected/local labels have an explicit fixture; if reviewers require a dedicated root-renderer field, report that scope expansion before touching index.ts. Raw JSON health remains unchanged. +4. Rebase semantically over #6807/#6809/#6802 and the current dev head. Tests intentionally make missing target enter the resolver by observing the invalid override warning; they do not depend on absence of #6807's PATH Bun fallback. +5. Current typecheck/layout verifier failures are dependency-preflight gaps (`bun-types`, `zod/v4`). Parent's dependency-equipped implementation checkout or exact-head CI must supply real passing evidence; this worker neither repaired dependencies nor claimed implementation success. +6. Reconcile goalplan c-1's Linux wording with accepted A9; preserve `/usr/bin/ocx` while adding the supported user shim/rc. This worker made no goalplan edits. +7. Windows machine PATH, cmd cwd, stale parent environments and shell aliases remain named bypasses. Native Windows selection/process smoke belongs to 030. Record intent or fixture success alone cannot close c-1/c-2. + +### Direct plan privacy observation + +The direct `scanText` verifier initially returned exit 1 for four **synthetic `/Users/example` home-path literals**: the scanner allows that placeholder under tests/docs-site but not under devlog. The complete test snippets now build the fixture path from `/Users/example` plus its suffix; their runtime example paths are unchanged. No real user path was introduced and the scanner itself was not modified. The subsequent direct plan result is recorded below; the earlier repository-wide PASS did not inspect this untracked file. + +```sh +bun -e 'import { readFileSync } from "node:fs"; import { scanText } from "./scripts/privacy-scan.ts"; const path = "devlog/_fin/261009_desktop_owned_path_cli/020_launcher_handoff_and_diagnostics.md"; const findings = scanText(path, readFileSync(path, "utf8")); console.log(`plan findings: ${findings.length}`); process.exit(findings.length ? 1 : 0);' +``` + +Direct plan verifier: **exit 0, 0 findings** after the placeholder construction edit; it explicitly reads this document through `readFileSync(path)` and `scanText(path, text)`. This is privacy-pattern proof for the plan, not runtime/type/test proof. diff --git a/devlog/_fin/261009_desktop_owned_path_cli/021_wp2_execution.md b/devlog/_fin/261009_desktop_owned_path_cli/021_wp2_execution.md new file mode 100644 index 00000000000..125b806da13 --- /dev/null +++ b/devlog/_fin/261009_desktop_owned_path_cli/021_wp2_execution.md @@ -0,0 +1,64 @@ +# 021 — wp2 execution plan (re-verified) + +wp2 implements 020 as amended by 000 (AM-1, AM-2) and 003 (AM-2 corrected, AM-5, AM-8). It runs on branch `codex/desktop-cli-handoff`, stacked on PR 1 (#6816, `codex/desktop-owned-path-cli` at bcbd0363be). This document and 003 win over 020 where they differ. + +## Stale check + +| 020 anchor | Current state on `origin/dev` 71c4ae3c8e + PR 1 | Action | +|---|---|---| +| `bin/ocx.mjs` update-help, `codexCliUpdateInspection`, mise/npm update branches, `resolveBun`, launch proof creation | Unchanged (1120 lines). #6807 (head 178f19a6f8) and #6809 (head f22a8b1ba5) are open and rebased on `dev`; both edit this file | Insert the handoff at the semantic point (after the inspection block, before the mise/npm update branch); rebase over #6807/#6809 when they land, anchoring on `const codexCliUpdateInspection` and the following mise `if` | +| `src/cli/status.ts` | #6802 merged (+70 lines: `selectStatusStartupHealth` returns `{ startup, startupSource }`, live supervision) | Add `cliCommand` as a separate additive field computed at `collectStatus` entry; do not touch `startupSource`/`serviceSummary` | +| `src/cli/doctor.ts` | #6802 changed restart-safety source and advice | New "ocx command selection" section after Paths, before response-state sections | +| `structure/runtime.md` | 600 lines (cap); #6802 changed 1 line | Edit the existing entrypoint row with zero line growth | +| `structure/ops/service-and-sidecars.md` | 541 → 541 (#6802 changed 4 lines) | +2 lines as planned | +| `src/service/managing-cli.ts`, `src/cli/launcher-context.ts` | Unchanged | Apply as planned | +| Record schema (wp1 actual) | Rust `Record`/`Bundle` use `camelCase` with `deny_unknown_fields`; fixtures in `tests/fixtures/desktop-cli-record/` (10 files, including `first-pending.json`) | TS reader consumes only `version`, `enabled`, `bundle.platform`, `bundle.kind`, `bundle.cliExecutable`, `pending`; tolerates other fields (the reader is not the writer) | + +## Amendments applied in wp2 + +- **AM-5:** the reader validates `bundle.kind` against the platform pair (`darwin`/`macos-app`, `win32`/`windows-install`, `linux`/`linux-deb`), refuses `.`/`..` segments, NUL, newline and paths over 4096 characters, and is tested against every file in `tests/fixtures/desktop-cli-record/` with the same expectations as the Rust test. `enabled:false` wins over `pending` (no target); enabled + pending is `record-pending`. Diagnostics report `cleanup-pending` for a disabled record with a pending journal. +- **AM-1:** reader bound stays 64 KiB, matching the writer cap. +- **AM-2 (corrected):** the launcher error for `record-invalid`, `record-too-large`, `record-unreadable` and `record-pending` reads: "opencodex: OpenCodex Desktop's terminal-command record at could not be used (). Open OpenCodex Desktop to repair the terminal command, or run with OCX_NO_DESKTOP_HANDOFF=1." No advice to delete the record. +- **AM-8:** after `spawn` fails with ENOENT, `lstat` the target; only a second ENOENT resumes the package path, otherwise print a fixed error and exit 127. Test: a target script whose interpreter is missing. + +## B worker split + +| Worker | Files | +|---|---| +| W4 handoff | NEW `src/lib/desktop-cli-record.mjs` + `.d.mts`, NEW `src/lib/desktop-cli-handoff.mjs` + `.d.mts`, MODIFY `bin/ocx.mjs` (hook + proof factory reuse), MODIFY `src/service/managing-cli.ts` (`OCX_NO_DESKTOP_HANDOFF=1` on internal probes), NEW `tests/cli/ocx-launcher-desktop-handoff.test.ts` | +| W5 diagnostics and docs | NEW `src/cli/cli-path-diagnostics.ts`, MODIFY `src/cli/status.ts`, `src/cli/doctor.ts`, NEW `tests/cli/cli-path-diagnostics.test.ts`, MODIFY `structure/runtime.md` (zero growth), `structure/cli-management.md`, `structure/ops/service-and-sidecars.md`, `docs-site/src/content/docs/getting-started/installation.md`, `docs-site/src/content/docs/reference/cli.md` | +| main | registrations of both new tests in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`; integration | + +W5 imports the record reader from W4's `src/lib/desktop-cli-record.mjs` using the export names in 020 (`readDesktopCliRecord`, `desktopCliRecordPath`, `DESKTOP_CLI_RECORD_MAX_BYTES`, type `DesktopCliRecordRead`). + +## C checks + +| Command | Reads the change? | +|---|---| +| `bun test tests/cli/ocx-launcher-desktop-handoff.test.ts tests/cli/cli-path-diagnostics.test.ts` | Yes, both new files and their imports | +| `bun test tests/cli/ocx-launcher-runtime.test.ts tests/cli/ocx-launcher-source.test.ts tests/service/managing-cli.test.ts tests/cli/cli-status-json.test.ts tests/codex-integration/doctor.test.ts` | Yes, existing launcher/probe/status/doctor contracts around the hooks | +| `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | Yes, registrations | +| `node --check bin/ocx.mjs`, `bun run typecheck`, `structure:check`, `privacy:scan`, file-size ratchet | Yes after staging | +| Manual: the dev-checkout `bin/ocx.mjs` run with an isolated `HOME` holding a valid record pointing at a fake executable prints the fake's output; with `OCX_NO_DESKTOP_HANDOFF=1` it does not | Yes, real launcher process | + +The A11 compiled check (AM-13) runs in wp3 against PR 1's shim, together with the security review of both PRs. + + +## Reflection (same architect, 01a11f35): ALIGNED + +Folded: + +- **Reader result type, fixed before B.** `DesktopCliRecordRead` is `{ state: "missing", path } | { state: "disabled", path, cleanupPending: boolean } | { state: "ready", path, record: { platform, kind, cliExecutable } } | { state: "invalid" | "unreadable", path, issue }`. W4 writes it in `.d.mts`; W5 consumes `cleanupPending` for the `cleanup-pending` diagnostic. +- **Fixture expectations map validity to consumption state**, not pass/fail: `valid-*` on its own host → `ready` (other hosts → `invalid`/`record-invalid`); `disabled-tombstone` → `disabled`, `cleanupPending:false`; `disabled-with-pending` → `disabled`, `cleanupPending:true`; `pending-enabled` and `first-pending` → `invalid`/`record-pending` (valid for Rust recovery, refused for handoff); `appimage-kind`, `relative-target`, `dotdot-target` → `invalid`/`record-invalid`. +- **Path rules match Rust exactly:** length counted in Unicode code points (`[...s].length`), Windows absolute means drive-rooted (`C:\\` or `C:/`) or UNC (`\\\\server\\share`) only; root-relative `\\ocx.exe` and drive-relative `C:ocx.exe` are refused. Extra boundary cases in the TS test: 4096 vs 4097 code points with an astral character, root-relative, drive-relative, UNC. +- **Manual smoke checks both sides:** with the record, `ocx --version` prints the fake target's marker; with `OCX_NO_DESKTOP_HANDOFF=1`, `ocx --version` prints the package version from `package.json` and exits 0. + + +## Audit (independent, 01a11f6b): NEAR-PASS + +Folded: + +- Every consumer uses the flattened reader result: `record.cliExecutable`, `record.platform`, `record.kind` (020's `record.bundle.cliExecutable` examples are superseded) in the handoff, diagnostics and test helpers; the real-launcher smoke is required in C because typecheck does not read `.mjs` bodies. +- Windows path rule restated to match Rust exactly: absolute means a drive root (`C:\\` / `C:/`) or a `\\\\` or `//` prefix; the server/share parts are not checked. TS boundary tests include `//server/share/ocx.exe` (accepted), `\\\\server` (accepted, same as Rust), `\\ocx.exe` (refused), `C:ocx.exe` (refused). +- After #6807/#6809 land, PR 2's rebase rereads `structure/runtime.md` (600-line cap), `structure/ops/service-and-sidecars.md`, `structure/cli-management.md` and the installation page, and reruns the launcher/managing-cli tests. + diff --git a/devlog/_fin/261009_desktop_owned_path_cli/030_docs_review_merge.md b/devlog/_fin/261009_desktop_owned_path_cli/030_docs_review_merge.md new file mode 100644 index 00000000000..7f483f63fe6 --- /dev/null +++ b/devlog/_fin/261009_desktop_owned_path_cli/030_docs_review_merge.md @@ -0,0 +1,53 @@ +# 030 — Docs, review, CI and merge (wp3) + +wp3 turns the two implementation phases into merged, documented behavior. It adds no product code of its own beyond review fixes. Its job is to prove the claims in 010 and 020 with evidence that observes them, get independent reviews, and land both PRs on `dev` in order. + +## Scope + +| IN | OUT | +|---|---| +| Final pass on docs written in wp1/wp2: desktop guide en/ko, installation pages, structure docs; a consistency sweep of the other locales | New locales or full translations of the new sections | +| Real-shell evidence for c-1/c-2 on macOS (zsh and bash) using a temporary `HOME` | Changing the maintainer's own shell files or PATH | +| Independent code review of PR 1 and PR 2; independent security review of the rc/PATH writes and the shim launch provenance (A11) | Maintainer human review (owner instruction: admin merge) | +| Exact-head hosted CI for both PRs, rerun of a failed job once when it is a known flake | Cancelling other contributors' runs | +| Admin squash merge of PR 1, retarget and rebase of PR 2, admin squash merge of PR 2, post-merge `dev` check | Release, version bump, deploy | + +## Evidence plan per criterion + +| Criterion | Evidence that observes it | Where it runs | +|---|---|---| +| c-1 app-only install gets `ocx` | Rust test drives `reconcile` against a temporary `HOME` with a fake stable bundle and asserts the shim, `path.sh` and rc blocks exist; a real `zsh -l -i -c 'command -v ocx'` and `bash -l -i -c 'command -v ocx'` with that `HOME` print the shim path. Windows: pure Path transform tests in CI; native registry write is not claimed as CI evidence. | `cargo test` locally on macOS and in the `desktop shell` job (Ubuntu, bash only); the real-shell check runs locally on macOS and is recorded in 039 | +| c-2 Desktop first ahead of npm | Same real-shell check with a fake npm `ocx` directory prepended earlier in `.zshrc`/`.bashrc` (written above the block, the way nvm/bun do); expect the shim to win. A second run appends an npm prepend *after* the block, then a Desktop relaunch (reconcile) moves the block back to the end; expect the shim to win again. | local macOS, recorded in 039 | +| c-3 launcher handoff | `tests/cli/ocx-launcher-desktop-handoff.test.ts` (020) | local focused run + hosted CI | +| c-4 refusal, idempotence, cleanup | Rust tests listed in 010; `tests/clients/desktop-cli-command-surface.test.ts` | `cargo test` + hosted CI | +| c-5 gates | `bun run typecheck`, the focused test files, `cargo fmt --check`/`clippy -D warnings`/`test`, `bun run structure:check`, `bun run privacy:scan`; exact-head Cross-platform CI including `desktop shell` | local + hosted | +| c-6 reviews | gpt-6.1-sol code review per PR (verdict PASS), gpt-6.1-sol security review of PR 1 (rc edits, PATH writes, record permissions, shim provenance) and of PR 2 (handoff exceptions, proof regeneration). Verdicts are pasted into the C>D attest of the owning phase. | subagents | +| c-7 merge | merge commit SHAs on `dev`, Co-authored-by not needed (no carried author), docs present in the merged tree | GitHub | + +The real-shell check is the only native evidence this unit can produce without packaging the app. A packaged smoke (installing a built OpenCodex.app and opening Terminal) remains human QA for the release checklist and is listed as residual in the PR descriptions. + +## Merge procedure + +1. PR 1 (`codex/desktop-owned-path-cli` → `dev`) after its gates pass. Before merging, rebase onto the latest `dev`, run `git merge-tree` against `origin/dev`, and rerun the focused checks if any of today's merges touched the same files. +2. PR 2 is stacked on PR 1's branch. After PR 1 merges, retarget PR 2 to `dev`, rebase it onto `dev` so PR 1's commits drop out, push, and wait for exact-head CI. +3. If lane L1's #6802/#6809/#6807 land before PR 2, rebase PR 2 onto them and rerun `tests/cli/ocx-launcher-*.test.ts` and the diagnostics tests; anchors in 020 are by function name. +4. Admin squash merge with `--match-head-commit`. After merging, confirm the squash commit on `dev` and that the next `dev` push CI is green or, when the Actions queue is backed up, that it is at least not red at the time of the report. + +## Docs checklist + +- `docs-site/src/content/docs/guides/desktop-app.md` and `ko/guides/desktop-app.md`: "The `ocx` terminal command" section (what Desktop writes, how to turn it off or remove it, new-terminal note, Windows system-`Path` caveat, AppImage exclusion), and the uninstall section updated to "remove the terminal command in Desktop first". +- Other locales of the desktop guide: no new section required, but no sentence may contradict the new behavior (for example "the desktop app does not add `ocx` to PATH"). Check with `rg -n "PATH|ocx" docs-site/src/content/docs/*/guides/desktop-app.md`. +- `structure/desktop-shell.md` (wp1) and `structure/runtime.md` (wp2, equal line count under the 600-line cap). + +## Done record + +wp3 closes with `039_outcome.md`: PR numbers, head and merge SHAs, CI run ids, review verdicts, real-shell transcript summary, residuals (Windows native selection, packaged smoke, AppImage, open shells), and the criterion evidence written into the goalplan. + + +## Additions from the plan audit (003, AM-10..AM-13) + +- **Dashboard entry check.** Besides the GUI unit test and source assertions, C records a real click-through: dashboard → "Terminal command" → `cli.html` → Back → dashboard. It needs a running Desktop build. When this unit cannot run a Desktop build locally, the check is listed as release QA in PR 1 and the GUI part is shown by the PR screenshot only; source assertions are never reported as this check. +- **A11 compiled check (AM-13).** Both runs use the same compiled host CLI. The control run starts from an environment without `OCX_NODE_LAUNCH_CONTEXT` and without any `--ocx-internal-launch-proof=` argument, so nothing from the shim run carries over. The dummy `ANTHROPIC_AUTH_TOKEN` value is a fixed non-secret string that does not match the proxy's own admission token format. Only variable names are printed. +- **Journal state table (AM-6, non-blocking item).** Rust tests cover install and remove × current digest equal to before / equal to after / different × operation wanted / not wanted, asserting that a different digest is never overwritten and that journal files are kept while any change is unresolved. The 64 KiB record cap stays separate from the raised `rcFiles ≤ 16` / `changes ≤ 32` limits. +- **Guard scope (AM-12).** The widened guard is used only by `return_to_dashboard`. The update commands keep `require_update_page`, and the four terminal-command commands keep the strict `require_cli_page`. Tests cover the `main` label, both allowed paths, and rejection of a foreign origin, another port and another path. + diff --git a/devlog/_fin/261009_desktop_owned_path_cli/031_wp3_review_fixes.md b/devlog/_fin/261009_desktop_owned_path_cli/031_wp3_review_fixes.md new file mode 100644 index 00000000000..b34ae53ae3a --- /dev/null +++ b/devlog/_fin/261009_desktop_owned_path_cli/031_wp3_review_fixes.md @@ -0,0 +1,67 @@ +# 031 — wp3 cycle 1: PR 1 review fixes + +PR 1 (#6816, `codex/desktop-owned-path-cli` at bcbd0363be) got two independent FAIL verdicts. This cycle fixes every P1 and P2 on the PR 1 branch, re-reviews, and then rebases PR 2 (#6818) onto it. Security details beyond what is written here stay out of the repository. + +## Findings and dispositions + +Code review (`01a11fad-f914`, FAIL): + +| # | Finding | Fix | Proof | +|---|---|---|---| +| R1 (P1) | A crash between writing journal files and saving the first pending record leaves journal files whose names the next attempt reuses with different bytes, so every later install fails with `journal-conflict` | Journal file names carry a per-transaction id (`--.json`, `txid` = random 128-bit hex created with the pending record). On lock acquisition, journal files not referenced by the current record's `pending` are unreferenced leftovers of an unsaved transaction and are deleted (they live in the private `journal/` dir and were never applied, because changes are applied only after the pending record is saved) | Rust test: write journal files, "crash" before saving the record, run install again → succeeds; leftover files removed | +| R2 (P1) | Windows: when `recover()` completes a registry change, the follow-up plan is empty and `WM_SETTINGCHANGE` is skipped; a failed broadcast is never retried | `recover()` reports whether it changed the registry; reconcile broadcasts when recovery or the plan changed the `Path`; the record's `windows` section gains `notifyPending: bool`, set before the registry write and cleared after a successful broadcast; Repair and the next launch broadcast while it is true | Rust test over the orchestration seam with a fake broadcaster: recovery-only change broadcasts once; failed broadcast sets `notifyPending` and the next run retries | +| R3 (P1) | Remove and Off call `posix::targets()` first, so a current `ZDOTDIR`/`XDG_CONFIG_HOME` that cannot be represented blocks removal of recorded files | Remove, Off and disabled reconcile use only the recorded ownership list (AM-7) and never compute current targets | Rust test: install, then set an unrepresentable `ZDOTDIR`; Remove succeeds and removes the recorded blocks | +| R4 (P2) | `cli.js`: an in-flight status poll that resolves after an action overwrites the action's result | Request generation counter: every action and poll takes a number; only the newest response renders; after an action, one fresh status read | `desktop-cli-command-surface.test.ts` VM case with delayed, reversed responses | +| R5 (P2) | Back from `cli.html` fails when the dashboard never became ready (startup failed), stranding the user away from Retry | `return_to_dashboard` falls back to the bundled startup page (`index.html`) when no ready dashboard exists, so Retry and diagnostics are reachable | Rust unit test on `return_ready_dashboard`'s fallback; surface assertion | + +Security review (`01a11fad-f9e1`, FAIL): + +| # | Finding | Fix | Proof | +|---|---|---|---| +| S1 (P1) | Private root, journal, backups and shim inherit ACL entries from the home directory; numeric 0700/0600 does not remove them | macOS: after creating `~/.opencodex-desktop` (and before creating anything inside), clear its extended ACL with `acl_set_file(path, ACL_TYPE_EXTENDED, acl_init(0))` and verify it is empty; subdirectories and files created afterwards inherit nothing. An existing root whose ACL cannot be cleared or verified is refused (`private-acl-unsafe`). Linux: remove `system.posix_acl_access` and `system.posix_acl_default` from the root, then verify absent; refuse otherwise. Windows: follow `src/lib/windows-secret-acl.ts`: grant the current user SID full control with object and container inheritance, then `/inheritance:r`, then remove broad SIDs (`*S-1-1-0`, `*S-1-5-11`, `*S-1-5-32-545`), via `icacls` with a timeout; failure refuses (`private-acl-unsafe`). FFI declarations only, no new crate | macOS Rust test: create a temp parent with an inheritable ACL (`chmod +a`), create the root through the store, assert no ACL on root and on a created journal file; Linux path unit-tested where `setfacl` exists, otherwise skipped with a message; Windows verified on `mini` with `icacls` output | +| S2 (P1) | Replacing an rc file by rename drops its ACL | An rc file with an extended ACL (macOS `acl_get_file` non-empty; Linux `system.posix_acl_access` present) is not edited; status reports `rc-acl-present` for that file. Private artifacts are hardened (S1); user rc ACLs are preserved by refusal | macOS Rust test: rc with `chmod +a "nobody deny read"` → install reports `rc-acl-present` and leaves bytes and ACL unchanged | + +## Worker split (PR 1 branch, one worktree) + +| Worker | Files | +|---|---| +| W6 Rust fixes | `desktop/src-tauri/src/cli_command_record.rs`, `cli_command_posix.rs`, `cli_command_windows.rs`, `cli_command.rs` (R1, R2, R3, S1, S2 and their tests) | +| W7 page and Back | `desktop/ui/cli.js`, `desktop/src-tauri/src/startup.rs` (`return_to_dashboard` fallback), `tests/clients/desktop-cli-command-surface.test.ts` (R4, R5) | + +## C checks + +macOS `cargo fmt --check`, `clippy --all-targets -D warnings`, `cargo test`, `cargo test real_shell -- --ignored`; Windows `mini` clippy + `cargo test cli_command` and an `icacls` readout of a hardened temp root; `bun test` surface/layout; then both reviewers re-review the fix commits and must return PASS (or NEAR-PASS with only P2 follow-ups). + + +## Reflection (same architect, 01a11f35): MISALIGNED → dispositions + +These replace the matching rows above. + +- **R1 (amended).** Leftover journal cleanup runs only under the lock, only after the current record was read and validated successfully, and only for regular files in `journal/` that match the journal name pattern and are not referenced by the record's `pending`. A corrupt or unreadable record never triggers cleanup. Why it is safe: a referenced journal is kept; an unreferenced file is either from an unsaved transaction (never applied) or from a transaction whose final record is already saved (its changes are recorded in the settled record), so neither is needed for recovery. The name validator accepts both `-.json` (records written by bcbd0363be) and `--.json`. Tests: crash before the pending save; crash after the final save and before journal deletion; corrupt record leaves the journal dir untouched; referenced journal kept. +- **R2 (amended).** The broadcast debt lives outside `windows`: a top-level record field `notifyPending: bool` with `#[serde(default)]` (old records read as false), set in the same record save that starts any registry change (install, repair, remove, rollback) and cleared only after a successful broadcast. It survives Remove (which clears `windows`). It is not `pending`; the 020 reader ignores it and handoff is never blocked by it. Fixtures: one record with `notifyPending: true` (reader → `ready`), and the existing fixtures without the field. +- **R3 (amended).** Every disabled path (Remove, Off, disabled reconcile at launch) reads the record first and uses only the recorded ownership list; `targets()` runs only for an enabled reconcile or Repair. Tests cover each of the three paths with an unrepresentable `ZDOTDIR`. +- **R4 (amended).** The generation check applies to errors as well as results; when a poll is in flight at the end of an action, the action schedules one more status read after that poll settles. Test covers both. +- **R5 (amended).** The main-label and exact-URL guard stay. Tests: ready dashboard → dashboard URL; no ready dashboard → bundled startup page. +- **S1 (amended).** Every Store open checks each existing private path (root, `bin`, `journal`, `backups`, `cli.json`, `lock`, shim, `path.sh`, each journal and backup file): owned by the current user, mode as expected, no extended ACL (macOS `acl_get_file` returns none or an empty ACL; Linux no `system.posix_acl_access`/`system.posix_acl_default` xattr). Newly created paths are cleared and then verified. Any existing path that fails is refused (`private-acl-unsafe`) and nothing is written; status tells the user which path. FFI: macOS `acl_get_file`/`acl_set_file`/`acl_init`/`acl_free`/`acl_get_entry` from libSystem with the documented constants, every allocation freed, any non-zero return treated as failure; Linux `getxattr`/`removexattr` from libc. Windows: `icacls` resolved as `%SystemRoot%\System32\icacls.exe` (absolute, `SystemRoot` read from the environment and validated as an absolute drive path), the current user SID from the process token (`OpenProcessToken` + `GetTokenInformation(TokenUser)` + `ConvertSidToStringSidW`), `Command` without a shell, a 10-second timeout after which the child is killed and waited, then `icacls ` output parsed to verify the DACL is protected (no inherited ACEs) and contains only the user SID, SYSTEM and Administrators; anything else refuses. +- **S2 (amended).** The rc ACL check runs before every rc write: install, reposition, removal, recovery replay and rollback. A refused file keeps its ownership entry so a later run can still remove the block once the ACL is gone. +- **C additions.** Linux ACL check runs where `setfacl` exists (CI Ubuntu has it; skip with a printed reason otherwise); ACL-failure and existing-child cases; reader regression over the updated fixtures after PR 2 rebases; `structure/desktop-shell.md` updated for `notifyPending`, leftover-journal cleanup and the ACL policy (within the 600-line cap). + + +## A audit round 1 (01a11f6b): FAIL → dispositions + +- **S1-macOS (blocker 1).** ACL inspection distinguishes three outcomes, each with its own return convention: `acl_get_file` returning NULL with `errno == ENOENT` means no ACL (accepted); a non-NULL ACL is walked with `acl_get_entry(acl, ACL_FIRST_ENTRY, &e)` then `ACL_NEXT_ENTRY`, where a return of `0` means an entry exists (refuse), and `-1` with `errno == EINVAL` on the first call means an empty ACL (accepted); any other NULL/`-1` is an inspection failure (refuse with `private-acl-unsafe`). Every ACL handle is freed with `acl_free`. Tests: empty ACL accepted; single inherited entry refused (created with `chmod +a` on a temp parent, and after clearing, accepted); inspection on a missing path is a failure; a fresh Store opens normally (positive case). +- **S1-Windows (blocker 2).** `icacls` is used only to set the policy (grant user SID with `(OI)(CI)F`, `/inheritance:r`, remove broad SIDs). Verification reads the native security descriptor with `GetNamedSecurityInfoW(OWNER | DACL)` and `GetSecurityDescriptorControl`: owner SID equals the token user SID; `SE_DACL_PROTECTED` is set; every ACE is an allow ACE for the user SID, `S-1-5-18` (SYSTEM) or `S-1-5-32-544` (Administrators), compared as SIDs with `EqualSid`, never by name. A newly created child is verified the same way except that inherited ACEs from the hardened root are allowed. Tests on `mini`: hardened root accepted; a root with inheritance not removed (unprotected, no inherited ACEs) refused; a root with an extra explicit ACE for `S-1-1-0` refused. FFI: `advapi32` declarations only, `LocalFree` for every returned buffer. +- **R1.** A missing record (first install) counts as a validated empty record: unreferenced journal files are cleaned under the lock in that case too. Test: crash before the first pending save on a fresh root → the next install succeeds. +- **R2.** Tests cover a settled record that keeps `notifyPending: true` after a failed broadcast, a crash after the registry write and before the broadcast, Remove and rollback, each retried on the next run. +- **R3.** Tests go through the real selection entry point (`perform`/`reconcile` with the environment-reading target selector) for Remove, Off and disabled reconcile, with `ZDOTDIR` set to a value containing `:`. +- **S2.** When an rc ACL refusal leaves a block in place during Remove, the shim and `path.sh` are kept as well (the block still sources `path.sh`), and status reports `rc-acl-present`. Test asserts the three survive together. +- **C.** The `desktop shell` CI job installs `acl` (provides `setfacl`) next to its Tauri packages; a skipped Linux ACL test is not counted as evidence. `structure/desktop-shell.md` changes replace existing lines to stay within 600. + + +## A audit round 2 (01a11f6b): FAIL → dispositions + +- **S1-Windows NULL DACL (blocker).** `GetNamedSecurityInfoW` must return a present, non-NULL DACL; a NULL DACL or a missing DACL refuses (`private-acl-unsafe`). The DACL must contain an allow ACE for the user SID granting `FILE_ALL_ACCESS` (or `GENERIC_ALL` mapped). Negative test on `mini`: owner and protection correct but DACL set to NULL with `SetNamedSecurityInfoW` → refused. +- **Root vs child rules.** Root: owner = user SID, `SE_DACL_PROTECTED`, only allowed SIDs, non-NULL DACL with the user's full-control ACE. Child: owner = user SID, non-NULL DACL, every ACE (inherited or explicit) for an allowed SID; protection not required. Tests: create root and a child, verify immediately, close, then open the Store again and verify both succeed. +- **Memory.** Only the security descriptor returned by `GetNamedSecurityInfoW` is released, with one `LocalFree`; owner and DACL pointers point inside it and are never freed separately. `ConvertSidToStringSidW` output (used only for logs, never compared) is freed with `LocalFree`. +- **macOS.** `NULL`/`ENOENT` from `acl_get_file` counts as "no ACL" only after an `lstat` of the same path succeeded; the missing-path test asserts failure. + diff --git a/devlog/_fin/261009_desktop_owned_path_cli/032_wp3_cycle1_outcome.md b/devlog/_fin/261009_desktop_owned_path_cli/032_wp3_cycle1_outcome.md new file mode 100644 index 00000000000..8cb9a60921c --- /dev/null +++ b/devlog/_fin/261009_desktop_owned_path_cli/032_wp3_cycle1_outcome.md @@ -0,0 +1,27 @@ +# 032 — wp3 cycle 1 outcome: PR 1 review fixes + +PR 1 (#6816) head a1326ccccd. Fix commits: 9c7a9e86fc (R1–R5, S1–S2 by W6 `01a11fc0-569d` and W7 `01a11fc0-57aa`), bcbf48d (exact unprotected-root test on Windows), 1f948d295f (structure doc, 600/600), a1326ccccd (R3 regression: every enabled reconcile observes the current bundle). + +## Reviews + +| Reviewer | Round 1 (bcbd0363be) | Final | +|---|---|---| +| Code (`01a11fad-f914`) | FAIL: R1–R3 P1, R4–R5 P2 | 1f948d295f: R1–R5 resolved, new P1 (stored record skipped bundle observation); a1326ccccd: **PASS** | +| Security (`01a11fad-f9e1`) | FAIL: S1–S2 P1 (inherited ACLs on private files; rc rewrite dropped ACLs) | 1f948d295f: **PASS** (macOS ACL return conventions, Linux xattrs, Windows owner/protected DACL/NULL DACL/allowed SIDs, icacls absolute path without shell and with timeout, fixed log codes) | + +## Evidence + +| Check | Result | +|---|---| +| macOS `cargo fmt --check` / `clippy --all-targets -D warnings` / `cargo test` | 0 / 0 / 246 passed, 2 ignored | +| macOS `cargo test real_shell -- --ignored` | 1 passed (real zsh and bash) | +| Windows 11 `mini` `clippy --all-targets -D warnings` / full `cargo test` | 0 / 188 passed (includes native DACL checks, NULL DACL refusal, root/child reopen) | +| `bun test` desktop CLI, update and startup surface | 43 pass | +| A11 compiled check (AM-13): host standalone CLI from `scripts/build-standalone.ts`, shim rendered by `render_shim_for_compiled_cli`, `claudeCode.enabled:false` (native route), fake `claude` printing protected names only, cwd `.env` with `ANTHROPIC_BASE_URL` | Shim + exported `ANTHROPIC_AUTH_TOKEN` → present; compiled CLI directly (control) → absent; shim + exported `ANTHROPIC_BASE_URL` → present; shim with `.env`-only `ANTHROPIC_BASE_URL` → absent. A11 verified. | + +The A11 check initially failed to render the shim under `/tmp`: on macOS `/tmp` is a symlink, and the file writer refuses symlinked ancestors, as designed. The check ran under a regular directory instead. + +## Still open (wp4) + +PR 2 review findings (security P1: record reader does not check owner/permissions/symlinks; code P2: FIFO record blocks the reader), rebase of both PRs onto `dev` after #6807 (merged 32abcc363d) and #6809, PR 1 GUI screenshot, exact-head CI, admin merges. + diff --git a/devlog/_fin/261009_desktop_owned_path_cli/040_wp4_land.md b/devlog/_fin/261009_desktop_owned_path_cli/040_wp4_land.md new file mode 100644 index 00000000000..4662f49b158 --- /dev/null +++ b/devlog/_fin/261009_desktop_owned_path_cli/040_wp4_land.md @@ -0,0 +1,62 @@ +# 040 — wp4: land both PRs + +wp4 takes PR 1 (#6816) and PR 2 (#6818) from review-complete to merged on `dev`. It fixes PR 2's review findings, rebases both PRs over lane L1's merged and pending launcher work, adds the GUI screenshot PR 1 needs, waits for exact-head CI, and admin-merges in stack order. + +## Current state + +- PR 1 head a1326ccccd + devlog commit 1cf9eada5c: code and security review PASS; merges cleanly into `origin/dev` (32abcc363d, after #6807). +- PR 2 head 53c2de3241 on the old PR 1: code review NEAR-PASS (P2), security review FAIL (P1). Against current `dev` it conflicts only in `structure/runtime.md` (with #6807); `bin/ocx.mjs`, `installation.md` and `service-and-sidecars.md` auto-merge. +- #6809 (L1) is open at f8fa8c5465 and also edits `bin/ocx.mjs`; it is rebasing onto #6807. + +## PR 2 fixes + +| # | Finding | Fix | Test | +|---|---|---|---| +| P2-S1 (security P1) | The record reader follows symlinks and accepts a record or directory writable by others, so a same-machine attacker who can write there chooses what `ocx` executes | POSIX: `lstat` `~/.opencodex-desktop` and `cli.json`; both must be owned by the effective uid, neither may be a symlink, and neither may have group or other permission bits (`mode & 0o077 === 0`, matching the writer's 0700/0600). Open with `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`, then `fstat` the descriptor and require a regular file with the same `dev`/`ino` as the `lstat`. Any failure is `record-unsafe` (new issue code, handled like the other errors: AM-2 message, no handoff). Windows: refuse when the directory or the record is a reparse point (`lstat().isSymbolicLink()` or a junction); ACL ownership is not read in the launcher hot path. The Desktop writer hardens and verifies this directory's protected DACL on every launch (031 S1), and the residual is stated in PR 2 | Unit tests on a temp HOME: mode 0666 record, 0777 directory, record symlink, directory symlink, foreign owner (skipped unless running as root, with a printed reason), each → `record-unsafe` and no spawn; valid 0700/0600 → `ready` | +| P2-C1 (code P2) | A FIFO at `cli.json` blocks `openSync` forever | Covered by `O_NONBLOCK` + regular-file check above | Subprocess test with `mkfifo` and a 2-second timeout → `record-unsafe` or `record-invalid` returned promptly | + +The diagnostics (`status`/`doctor`) use the same reader and report `record-unsafe` as FAIL with the path. + +## Rebase and order + +1. PR 1: rebase `codex/desktop-owned-path-cli` onto the latest `origin/dev` (clean today), push, and wait for exact-head CI. +2. PR 2: rebase `codex/desktop-cli-handoff` onto the rebased PR 1, resolve `structure/runtime.md` by keeping #6807's text and re-applying this PR's single-row change at zero line growth, re-run the launcher, managing-cli, handoff and diagnostics tests, then push. +3. If #6809 lands first, PR 2 rebases again over it; `bin/ocx.mjs` anchors are `const codexCliUpdateInspection` and the mise `if`. + +## PR 1 screenshot + +PR 1 changes `gui/`, so `enforce-target` requires a screenshot. Start an isolated proxy from PR 1's tree (`OPENCODEX_HOME` and `HOME` in a temp directory, an unused port), open the dashboard in headless Chrome with a user agent that contains `OpenCodexDesktop/2.82.0` so `isDesktopShell()` is true, and capture the sidebar showing "Terminal command" next to the Desktop update row. Upload the image to the `pr-assets` branch and link it by commit SHA in PR 1's description (AGENTS.md). Also attach the rendered `cli.html` screenshot from wp1. + +## Merge + +1. PR 1: all non-policy checks pass on its exact head, both reviews PASS, merge-tree clean → admin squash merge with `--match-head-commit`; mark ready first. +2. PR 2: retarget to `dev`, rebase so PR 1's commits drop out, push, both reviewers re-check the fix commits and return PASS, exact-head CI → admin squash merge. +3. After both: update the goalplan criteria with evidence, write `049_outcome.md`, and close the goal. + +## C checks + +PR 2 focused tests (handoff, diagnostics, launcher runtime/source, managing-cli, status JSON, doctor, layout), `node --check bin/ocx.mjs`, typecheck, structure, privacy, ratchet; the real-launcher smoke from 021; exact-head CI for both PRs; merge SHAs on `dev`. + + +## Reflection (same architect, 01a11f35): MISALIGNED → dispositions + +These replace the matching parts above. + +- **Windows: no package-launcher handoff.** Reading the current owner and DACL from the Node launcher needs a PowerShell/.NET or native helper call on every `ocx` invocation, and trusting the writer's last verification does not cover a launcher that runs before Desktop or after Desktop refused an unsafe state. On Windows the reader still runs for `status`/`doctor` diagnostics, but `planDesktopCliHandoff` returns "continue" (no handoff) on `win32`. The Windows path to the Desktop CLI is the user `Path` entry from PR 1, which puts the Desktop install directory ahead of `%APPDATA%\\npm` in new shells. PR 2's description and the installation page say so. Test: on a simulated `win32` platform a valid record produces no spawn. +- **POSIX reader order and checks.** Safety checks run before any JSON parsing or disabled/pending interpretation: `lstat` of `~/.opencodex-desktop` must be a real directory (not a symlink), owned by the effective uid, `mode & 0o077 === 0`; `lstat` of `cli.json` must be a regular file, not a symlink, same owner, `mode & 0o077 === 0`; open with `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`; `fstat` must report a regular file with the same `dev`/`ino`, owner and mode bits as the `lstat` (replacement between `lstat` and `open` fails). macOS extended ACLs: `/bin/ls -lde ` (absolute path, no shell, 1-second timeout) must list no ACL entries; any entry or a failed or timed-out check → `record-unsafe`. Linux: a POSIX ACL shows up in the group bits (mask), which the mode check already refuses; no extra call. Platform/kind, path rules, 64 KiB, disabled-over-pending stay as before. `.d.mts` issue union and the diagnostics' issue list gain `record-unsafe`. +- **Tests.** Added: directory symlink, record symlink, 0666 record, 0777 directory, record replaced between `lstat` and `open` (simulated through the injected fs seam), FIFO with a 2-second timeout, macOS ACL entry on the directory and on the record (`chmod +a` on temp paths; skipped off macOS with a printed reason), valid 0700/0600 → `ready`. +- **Rebase review.** After auto-merging `bin/ocx.mjs` with #6807 (and #6809 if it lands first), compare the proof factory, the maintenance/update exceptions and #6807's PATH Bun fallback against both parents, then rerun the launcher runtime/source, handoff, managing-cli and smoke checks. +- **Merge gate for these two PRs.** Every check on the exact head passes, policy checks included (`enforce-target`, PR hygiene); a queued policy check is rerun and waited for instead of being treated as an exception. `--match-head-commit` and a fresh `merge-tree` against the latest `dev` immediately before each merge. +- **Screenshot.** Taken from PR 1's merge-candidate GUI build; the PR text says the Desktop shell was simulated with a user-agent override, so it shows the sidebar entry only, not Tauri navigation. +- **Close-out.** `049_outcome.md` records the post-merge `dev` CI result and the remaining packaged/native QA items (packaged click-through, fish, Linux deb, Windows terminal selection after a real registry write). + + +## Audit (independent, 01a11f6b): NEAR-PASS + +Folded: Windows returns "continue" before any record read or target check, with tests for valid, unreadable and unsafe records; reader fixtures are written with explicit 0600 files in 0700 directories, and foreign ownership is also tested through the injected stat seam; the FIFO test passes only when the reader returns a refusal within the time limit (a timeout is a failure); Windows handoff expectations, the installation page and the CLI reference are updated together; the screenshot is embedded as an image in PR 1's description; after PR 1's squash, PR 2 is rebased with `git rebase --onto origin/dev ` and re-reviewed and re-tested on its final head. Current red checks (PR 1 `enforce-target` for the missing screenshot and `test 2/4`; PR 2 `ci`) are inspected and fixed or rerun before any merge. + + +## B finding: no child-process kill in the desktop shell + +PR 1's CI (`test 2/4`) failed `tests/clients/desktop-exit-ownership.test.ts` "no file in the shell kills the runtime process": the shell sources must not contain `.kill()`, and the `icacls` timeout in `cli_command_windows.rs` killed its child. The invariant stays. The Windows hardening is rewritten to set the DACL natively instead of running `icacls`: build an ACL with `InitializeAcl` and `AddAccessAllowedAceEx` (`OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE` on directories) for the token user SID (full control), SYSTEM and Administrators, then `SetNamedSecurityInfoW(path, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, ...)`, then the existing native verification. No child process, no trusted executable path, no timeout. Tests on `mini` stay as they are (hardened root, unprotected root, NULL DACL, extra Everyone ACE, root/child reopen). The security reviewer re-checks this change. + diff --git a/devlog/_fin/261009_desktop_owned_path_cli/049_outcome.md b/devlog/_fin/261009_desktop_owned_path_cli/049_outcome.md new file mode 100644 index 00000000000..8924ac77330 --- /dev/null +++ b/devlog/_fin/261009_desktop_owned_path_cli/049_outcome.md @@ -0,0 +1,32 @@ +# 049 — wp4 outcome: both PRs on `dev` + +## Landed + +| PR | Squash on `dev` | Exact-head evidence | +|---|---|---| +| #6816 Desktop owns the `ocx` terminal command on PATH | b89bbfb083 | head 2103c18b4f (rebased on #6820 bb36029ef9). Cross-platform CI 37922442745 success on attempt 2: `macos 1/2` failed on the inherited codex-prompt-lock contender deadline (fixed on `dev` by #6808) and `windows 8/9` on an AUTH-11 15 s runner timeout; only those two jobs were rerun. Service lifecycle 37922442829 success on all three hosts. Combined tree `dev`+#6808+#6816: typecheck, structure:check, ratchet and layout guards pass. | +| #6818 Package launchers hand off to the Desktop `ocx`; status/doctor report PATH selection | this PR | Retargeted to `dev` after #6816 and rebased with `--onto`; final evidence is recorded on the PR. | + +Admin squash merges under the maintainer instruction to integrate through admin review on `dev`. Code and security reviews passed for both PRs (031, 032 and the PR 2 rounds recorded in 040). + +## Late fix in wp4 + +The CI-only failure of `disabled pending cleanup warns without selecting a target` (Linux `test 4/4`, `windows 9/9`) came from the test, not the reader. It reads a macOS record on every host, so Linux ran the macOS-only `ls -lde` ACL probe and Windows reported NTFS modes the POSIX owner/mode check rejects; both returned `record-unsafe`. The test now passes the same host-safe stats seam the handoff suite already uses and hands the read to the diagnostics. Verified on macOS and on Windows `mini` (13 pass, 2 skip). + +A `dev` regression from #6811 (`ocx stop` failing with `realpath ENOENT` when `~/.codex` is absent) held PR 1's Service lifecycle jobs red until lane L2 landed #6820. + +## Criteria + +| Criterion | Evidence | +|---|---| +| c1 app-only install gets `ocx` on PATH (macOS, Windows; deb unchanged) | #6816 Rust installer, shim and PATH writers; cargo tests on macOS (246) and Windows (191) | +| c2 Desktop `ocx` first in a new login shell; Windows user PATH order | rc-block ordering tests, `real_shell` ignored test (zsh, bash), HKCU Path order tests | +| c3 launcher handoff with recursion guard and escape hatch | #6818 `ocx-launcher-desktop-handoff.test.ts`, real-launcher smoke | +| c4 foreign-file refusal, idempotence, opt-out cleanup | Rust record/journal tests and TS record-reader safety tests | +| c5 local gates and exact-head CI | typecheck, structure:check, focused bun tests, cargo test/clippy, privacy:scan; CI above | +| c6 independent sol code and security review PASS | 032 and 040 | +| c7 admin squash merge with docs | b89bbfb083 and this PR, docs-site installation and CLI reference | + +## Not verified here + +Packaged-app click-through of the CLI page, fish on a real login, the Linux deb path, and Windows terminal pickup after a real registry write in a fresh session are left to release QA. No release, version bump, or promotion was done. diff --git a/devlog/_fin/261009_n3_test_infra/000_plan.md b/devlog/_fin/261009_n3_test_infra/000_plan.md new file mode 100644 index 00000000000..9ea06449a1b --- /dev/null +++ b/devlog/_fin/261009_n3_test_infra/000_plan.md @@ -0,0 +1,69 @@ +# N3 test infrastructure: client-home isolation and fixture determinism + +Unit for three test-infrastructure defects reported against dev in October 2026. +Each item ships as its own pull request to `dev` so a reviewer can judge the +home-isolation change apart from the two fixture fixes. + +| Doc | Issue | Change | PR branch | +| --- | --- | --- | --- | +| 010 | #6775 | Pin client homes in the test sandbox, resolve the Claude default home at call time, and make the test guard refuse writes to the real Claude config directory | `codex/n3-test-infra` | +| 020 | #6777 | Stop the combo management fixture from reaching provider discovery and keep a timed-out case from running into the next fixture | `codex/n3-combo-rename-fixture` | +| 030 | #6776 | Give the native Codex toggle fixture deterministic service-manager evidence | `codex/n3-codex-toggle-fixture` | + +## Constraints + +- Production behavior stays the same. The only production edits are in 010: + `claudeConfigDir()` reads the home the way Node's `os.homedir()` does at call + time, and two Claude writers call a guard that is inert unless + `OCX_TEST_HOME_GUARD=1`. +- Other `homedir()` call sites (OpenCodex config dir, Codex home, client + integrations) are deliberately out of scope. Moving them to an environment-first + home would change where production reads its own config on Windows hosts whose + `HOME` differs from the profile directory. The sandbox and the guard cover tests + without that risk. +- Workflows under `.github/` are not touched (L7 #6806 owns CI changes). +- Every local test run starts Bun with a temporary `HOME`, `USERPROFILE` and + `CLAUDE_CONFIG_DIR`; a bare `bun test` against the developer home is not used, + because that is the defect in #6775. +- File-size ratchet: no capped file grows past its cap. New tests are registered in + `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. + +## Order + +010, 020 and 030 are independent; each starts from `origin/dev`. 010 goes first +because the other two reproductions are only safe once the sandbox pins the Claude +home. + +## Verification + +Focused files per doc plus `bun run typecheck`, run with a temporary home at +process start. The full suite runs in hosted CI on each PR's exact head. + + +## Outcome + +**DONE**, 2026-10-09. All three items merged into `dev` with squash admin merges, after +exact-head CI concluded `success` and independent gpt-6.1-sol reviews passed: + +| Doc | Issue | PR | Merge commit | Exact-head CI | +| --- | --- | --- | --- | --- | +| 010 | #6775 | #6835 | `7f642e30e8` | all executed jobs passed at `9ac1a95263`, including the nine Windows shards, `npm-global windows-latest` and `keyring windows` | +| 020 | #6777 | #6833 | `6d7e5f48ad` | all executed jobs passed at `0fa7ee87df`; Windows shards and other conditional jobs skipped (not selected for this diff) | +| 030 | #6776 | #6834 | `c4baa30e09` | all executed jobs passed at `c6addbb97e`; Windows shards and other conditional jobs skipped (not selected for this diff) | + +Before merging, the three heads were combined on `dev` `b89bbfb083` (#6835 shares +`layout.json` and `test-layout-expected.json` with changes that landed meanwhile): 180 +focused tests, typecheck and `structure:check` passed. The three issues were closed with +a comment naming the PR and merge commit. + +010 went through four review rounds. The plan's two guarded writers grew to four (agent +sync, gateway cache, served-catalog invalidation, intercept `settings.json`), and the guard +now judges every touched path by where it resolves (directory and file links), resolves its +roots at check time, compares case-insensitively on macOS and Windows, splits containment +on the platform separator, and covers removals with the same comparison while keeping the +checkout-content lift. + +Not done here: other client homes that still default to `os.homedir()` (Claude Desktop +config library, several client-integration writers, Kiro, XDG-based clients) remain +follow-up candidates. Native Windows was exercised only through hosted CI. + diff --git a/devlog/_fin/261009_n3_test_infra/010_6775_client_home_isolation.md b/devlog/_fin/261009_n3_test_infra/010_6775_client_home_isolation.md new file mode 100644 index 00000000000..23d9a52c339 --- /dev/null +++ b/devlog/_fin/261009_n3_test_infra/010_6775_client_home_isolation.md @@ -0,0 +1,195 @@ +# 010 — #6775: keep tests out of the real Claude config directory + +## Defect + +`tests/preload.ts` sandboxes a bare `bun test` by assigning `process.env.HOME` +after Bun has started. Bun's `os.homedir()` keeps the start-time home, so +`claudeConfigDir()` in `src/claude/gateway-cache.ts` resolves the developer's real +`~/.claude` when `CLAUDE_CONFIG_DIR` is unset. Management toggles call +`syncClaudeAgentDefsBestEffort()` (`src/server/management-api.ts`) with no +explicit directory, and `syncClaudeAgentDefs([])` prunes every generated +`ocx-*.md` there. `tests/claude-integration/claude-messages-endpoint.test.ts` +("compatibility survives management toggles…") triggers it. + +Reproduced on `730d898457` with a temporary HOME at process start: a planted +generated `ocx-probe.md` in the fake home is removed by that one test. + +## Change + +### 1. Sandbox pins the Claude and Grok homes — MODIFY `scripts/test.ts` + +`createIsolatedTestEnvironment()` already feeds both the wrapper child env and the +preload. Add, next to `CODEX_HOME`: + +```diff + OPENCODEX_HOME: opencodexHome, + CODEX_HOME: codexHome, ++ // Client homes that default to os.homedir(). Bun keeps the start-time home, so a ++ // preload that only rewrites HOME leaves these pointing at the developer's real ++ // directories. Pin them inside the sandbox; never inherit a live override. ++ CLAUDE_CONFIG_DIR: join(root, ".claude"), ++ GROK_HOME: join(root, ".grok"), +``` + +and capture the real Claude directory for the guard before it is replaced: + +```diff + OCX_REAL_HOME: baseEnv.OCX_REAL_HOME ?? homedir(), ++ OCX_REAL_CLAUDE_CONFIG_DIR: baseEnv.OCX_REAL_CLAUDE_CONFIG_DIR ++ ?? (baseEnv.CLAUDE_CONFIG_DIR?.trim() || join(baseEnv.OCX_REAL_HOME ?? homedir(), ".claude")), +``` + +### 2. Guard refuses the real Claude directory — MODIFY `src/lib/test-home-guard.ts` + +Capture at module load (same moment as `REAL_HOME`), protecting the default +`/.claude` and, when it differs, the developer's own +`CLAUDE_CONFIG_DIR` handed over as `OCX_REAL_CLAUDE_CONFIG_DIR`: + +```ts +const REAL_CLAUDE_DIRS = [...new Set([ + join(REAL_HOME, ".claude"), + process.env.OCX_REAL_CLAUDE_CONFIG_DIR?.trim() || process.env.CLAUDE_CONFIG_DIR?.trim() || join(REAL_HOME, ".claude"), +])]; +const PROTECTED_CLAUDE_TREES = REAL_CLAUDE_DIRS.map(path => ({ + path: canonicalize(path), lexical: resolve(path), label: "the real Claude config directory", +})); + +export function protectedClaudeConfigDirsForTests(): readonly string[]; +/** Throw when an armed test process is about to write or prune inside the real Claude config dir. */ +export function assertNotRealClaudeConfigUnderTest(dir: string): void; +``` + +`assertNotRealClaudeConfigUnderTest` is a no-op unless armed, and refuses when +`dir` (canonical or lexical) equals or sits inside a protected Claude tree. The +Claude trees are appended to `PROTECTED_TREES` so `assertRemovalOutsideProtectedTrees` +refuses removals there too. + +Note on the capture: under `bun run test` the wrapper child already has a +sandboxed `CLAUDE_CONFIG_DIR`, which is why the real value travels in +`OCX_REAL_CLAUDE_CONFIG_DIR`. Under a bare run the preload imports the guard +before it rewrites the environment, so `process.env.CLAUDE_CONFIG_DIR` is still +the developer's own value. + +### 3. Writers call the guard first — MODIFY `src/claude/agents-inject.ts`, `src/claude/gateway-cache.ts` + +```diff + export function syncClaudeAgentDefs(defs, configDir = claudeConfigDir()) { ++ // Outside the best-effort catch: under an armed test process a write or prune of the ++ // real Claude directory must fail loudly, never degrade to "returned null". ++ assertNotRealClaudeConfigUnderTest(configDir); + try { +``` + +Same first line in `writeGatewayModelCache`. Callers that wrap these in their own +best-effort catch still never reach the filesystem. + +### 4. Claude default home follows the current environment — MODIFY `src/claude/gateway-cache.ts` + +```diff + export function claudeConfigDir(): string { + const custom = process.env.CLAUDE_CONFIG_DIR; +- return custom && custom.length > 0 ? custom : join(homedir(), ".claude"); ++ return custom && custom.length > 0 ? custom : join(currentUserHome(), ".claude"); + } ++ ++/** ++ * The home Claude Code itself resolves. Claude Code runs on Node, whose os.homedir() ++ * consults HOME (POSIX) or USERPROFILE (Windows) at call time when it is set; Bun's ++ * caches the start-time value. In production the two agree; under a preload that ++ * rewrites HOME only this one follows the sandbox. Exported as an internal for tests. ++ */ ++export function currentUserHome(env = process.env, platform = process.platform): string { ++ const fromEnv = platform === "win32" ? env.USERPROFILE : env.HOME; ++ return fromEnv !== undefined && fromEnv !== "" ? fromEnv : homedir(); ++} +``` + +### 5. Existing resolver test follows the new contract — MODIFY `tests/claude-integration/claude-gateway-cache.test.ts` + +"claudeConfigDir honors CLAUDE_CONFIG_DIR" deletes `CLAUDE_CONFIG_DIR` and expects +`join(os.homedir(), ".claude")`, Bun's cached start-time home. Under the preload that is the +developer's real home, so the assertion encodes the defect. New fallback expectation: + +```diff +- const { homedir } = require("node:os") as typeof import("node:os"); +- expect(claudeConfigDir()).toBe(join(homedir(), ".claude")); ++ const home = process.platform === "win32" ? process.env.USERPROFILE : process.env.HOME; ++ expect(claudeConfigDir()).toBe(join(home!, ".claude")); +``` + +### 6. The triggering test names its directory — MODIFY `tests/claude-integration/claude-messages-endpoint.test.ts` + +Pass `{ managementApi: { claudeAgentConfigDir: join(testDir, "claude-agents") } }` +to `startServer` in the toggle test (in-place, no line growth; file is at 1993/2000). + +## Regression tests — NEW `tests/claude-integration/claude-config-home-isolation.test.ts` + +1. `createIsolatedTestEnvironment({ CLAUDE_CONFIG_DIR: "/inherited", GROK_HOME: "/inherited" })` + pins both inside `isolated.root` and records `OCX_REAL_CLAUDE_CONFIG_DIR: "/inherited"`. + Fails on dev (inherited values pass through). +2. With `CLAUDE_CONFIG_DIR` unset and `HOME`/`USERPROFILE` reassigned in-process, + `claudeConfigDir()` returns `/.claude`. Fails on dev under Bun. +3. A child Bun process armed with `OCX_TEST_HOME_GUARD=1` and + `OCX_REAL_HOME=` calls `syncClaudeAgentDefs([], /.claude)`: + the call throws the guard error and the planted generated file survives. On dev + the file is pruned. The sentinel is a temp directory, so the real home is never + involved. +4. The preload itself: inside any test process, `process.env.CLAUDE_CONFIG_DIR` + resolves under the sandbox root, not under `OCX_REAL_HOME`. +5. HOME and USERPROFILE disagree: `currentUserHome({HOME:a, USERPROFILE:b}, "linux")` + is `a`, with `"win32"` it is `b`, and an empty variable falls back to `os.homedir()`. +6. Nested isolation keeps the original: `createIsolatedTestEnvironment(isolated.env)` + preserves the outer `OCX_REAL_CLAUDE_CONFIG_DIR`. + +Register the new file in `scripts/test-layout/layout.json` (`explicit`) and +`tests/fixtures/test-layout-expected.json` under `claude-integration`. + +Extend `tests/ci-workflows/test-home-guard.test.ts` only if its roster asserts the +exact protected-tree list (check `protectedRemovalTreesForTests` expectations). + +## Structure docs + +Source areas touched: `scripts/`, `src/claude/`, `src/lib/`, `tests/`. Every doc mapped for +them in `structure/INDEX.md` is reviewed. The contract that changes is the test sandbox, +described by `structure/ops/test-sandbox-cleanup.md` (maps `tests/`). MODIFY it with one +present-tense paragraph: the sandbox pins `CLAUDE_CONFIG_DIR` and `GROK_HOME` inside its +root and hands the real Claude directory to the guard as `OCX_REAL_CLAUDE_CONFIG_DIR`; +`claudeConfigDir()` follows the current platform home variable; the armed guard refuses +Claude agent sync and gateway-cache writes into the real Claude config directory (default and +the developer's own `CLAUDE_CONFIG_DIR`) and refuses removals there; pinned by +`tests/claude-integration/claude-config-home-isolation.test.ts`. The other mapped docs +(`runtime.md`, `clients/claude-desktop.md`, `overview.md`, `ops/docs-and-release.md`, and the +`src/lib/` transport/dashboard docs) describe no behavior this change alters; the PR records +that review. + +## Out of scope, reported in the PR + +The research inventory found other client homes resolved through `homedir()` +(Claude Desktop config library, client integration writers, Kiro, XDG-based +clients). The sandbox now pins Claude and Grok; the rest are follow-up candidates. + +## Verify + +```sh +FH=$(mktemp -d); env -u CLAUDE_CONFIG_DIR -u GROK_HOME HOME="$FH" USERPROFILE="$FH" CLAUDE_CONFIG_DIR="$FH/.claude" \ + bun test tests/claude-integration/claude-config-home-isolation.test.ts tests/ci-workflows/test-home-guard.test.ts \ + tests/claude-integration/claude-messages-endpoint.test.ts tests/claude-integration/claude-agents-inject.test.ts \ + tests/claude-integration/claude-gateway-cache.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts \ + tests/ci-workflows/structure-ssot.test.ts tests/ci-workflows/file-size-ratchet.test.ts +bun run typecheck +bun run structure:check +``` +plus the issue's probe: plant a generated `ocx-probe.md` in `$FH/.claude/agents`, +start Bun with `CLAUDE_CONFIG_DIR` unset, and confirm it survives. + + +## Implementation record + +Merged as PR #6835 (`7f642e30e8`). Beyond this doc, review added: guarded served-catalog +invalidation (`src/claude/intercept/cli-catalog.ts`) and the intercept `settings.json` +writer (`src/claude/intercept/settings.ts`); every touched path, including +`cache/gateway-models.json` and each agent definition's temporary file, is passed to the +guard; roots resolve at check time with case folding on macOS and Windows; removal +protection uses the same comparison and keeps the checkout-content lift. The regression file +has eight cases, all failing on `dev` before the change. + diff --git a/devlog/_fin/261009_n3_test_infra/020_6777_combo_fixture.md b/devlog/_fin/261009_n3_test_infra/020_6777_combo_fixture.md new file mode 100644 index 00000000000..b51339725ca --- /dev/null +++ b/devlog/_fin/261009_n3_test_infra/020_6777_combo_fixture.md @@ -0,0 +1,110 @@ +# 020 — #6777: combo management fixture stays local and serial + +## Defect + +`tests/routing/combo-management-api.test.ts` builds providers `a/b/c` with +synthetic `https://*.example/v1` URLs and live discovery on. The rename route +awaits `syncClaudeAgentDefsBestEffort` → `fetchAllModels` → provider discovery → +`providerOutboundGet` → DNS lookup, which is not bounded by the request's abort +signal. When DNS is slow the case passes Bun's 5 s timeout; Bun then starts the next +case while the first callback is still running, so the first reads the second's +fixture and its `finally` restores the environment under the second (the reported +`new-public`/`stable-public` mismatch and `ENOENT config.json`). + +Hosted CI resolves DNS fast and uses `--timeout 60000`, which hides it. + +The route's await is correct and stays: the test asserts that agent definitions are +migrated before the response returns. + +## Change — MODIFY `tests/routing/combo-management-api.test.ts` only + +1. `baseConfig`: add `liveModels: false` to providers `a`, `b`, `c` (static + catalog path in `src/codex/catalog/provider-models.ts:239`). +2. Serialize the fixture helper and drain it, with a bound, before shared state is + cleared. A drain that exceeds the bound poisons the helper: later owners reject + immediately instead of queueing behind a callback that may still resume and rewrite + the environment. + +```diff +-async function withTempHome(run: (dir: string) => Promise | T): Promise { ++// Bun starts the next case while a timed-out one is still running. Chaining owners ++// keeps one case's environment from being installed or restored under another's. ++let tempHomeSettled: Promise = Promise.resolve(); ++let tempHomeStuck = false; ++let tempHomeDrainMs = 50_000; // lowered only by the poison regression ++const STUCK = "an earlier withTempHome owner never settled; refusing to share its environment"; ++function withTempHome(run: (dir: string) => Promise | T): Promise { ++ if (tempHomeStuck) return Promise.reject(new Error(STUCK)); ++ // Rechecked when the queued owner starts: one queued before poisoning must not run ++ // after a stalled predecessor finally settles. ++ const operation = tempHomeSettled.then(() => { ++ if (tempHomeStuck) throw new Error(STUCK); ++ return runWithTempHome(run); ++ }); ++ tempHomeSettled = operation.then(() => {}, () => {}); ++ return operation; ++} ++ ++async function drainTempHome(): Promise { ++ // Once poisoned, every later drain fails at once instead of waiting out the bound again. ++ if (tempHomeStuck) throw new Error(STUCK); ++ let timer: ReturnType | undefined; ++ const drained = await Promise.race([ ++ tempHomeSettled.then(() => true), ++ new Promise(resolve => { timer = setTimeout(() => resolve(false), tempHomeDrainMs); }), ++ ]); ++ clearTimeout(timer); ++ if (!drained) { ++ tempHomeStuck = true; ++ throw new Error("withTempHome owner still running after the drain bound"); ++ } ++} ++ ++async function runWithTempHome(run: (dir: string) => Promise | T): Promise { +``` + +```diff +-afterEach(() => { ++afterEach(async () => { ++ await drainTempHome(); + clearComboSelectionState(); + clearComboTargetCooldowns(); +-}); ++}, 60_000); +``` + +3. Regression in the rename case without re-indenting it: install + `spyOn(outbound, "providerOutboundGet").mockRejectedValue(...)` at the top of the + case, register its restore in an `afterEach` cleanup list, and assert + `expect(discovery).not.toHaveBeenCalled()` after `withTempHome` resolves. On dev + the spy records the a/b/c discovery calls. +4. Serialization regression at file end: two overlapping `withTempHome` owners; + the first holds a gate and asserts its own `OPENCODEX_HOME` after the second was + requested. On dev the second owner rewrites the environment under the first. + The gate is released in `finally` so a failing assertion cannot strand the queue. +5. Poison regression: with a lowered drain bound, an owner that never settles makes + `drainTempHome()` throw; afterwards (a) a second drain rejects immediately, (b) a new + `withTempHome` rejects immediately, and (c) an owner queued before poisoning rejects + without running its callback when the stalled owner is released. Gate release, + settlement of every owner, and restoration of the bound, the poison flag and the queue + head all happen in `finally`, so a failed assertion cannot leave the file poisoned. + The test-only reset is a small `resetTempHomeQueueForTest()` function in the test file. + +## Verify + +```sh +FH=$(mktemp -d); env -u HTTPS_PROXY -u NODE_EXTRA_CA_CERTS HOME="$FH" USERPROFILE="$FH" CLAUDE_CONFIG_DIR="$FH/.claude" \ + bun test tests/routing/combo-management-api.test.ts +``` + + +## Implementation record + +Implemented as written in PR #6833 (branch `codex/n3-combo-rename-fixture`, commit `0fa7ee87df`), +test-only, in `tests/routing/combo-management-api.test.ts` (1,913 lines, under the ratchet cap, so +no sibling file was needed). Before the change the three regressions fail (rename: 3 discovery +calls; serialization: the first owner observes the second's `OPENCODEX_HOME`; poison: the drain +resolves). After: 41/41 in the file under a fresh startup home; independent review also ran it with +CI's `--isolate --timeout 60000` flags (77/77 across four files) and confirmed the timeout probe +drains the owner before the next case. + diff --git a/devlog/_fin/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md b/devlog/_fin/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md new file mode 100644 index 00000000000..e1dd36a1577 --- /dev/null +++ b/devlog/_fin/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md @@ -0,0 +1,64 @@ +# 030 — #6776: native Codex toggle fixture owns its service-manager evidence + +## Defect + +`tests/codex-integration/native-codex-toggle.test.ts` writes a matching v2 +`service-state.json` into its synthetic `OPENCODEX_HOME`, then enables Codex +through the management route. Enable runs production admission, which calls +`inspectServiceManagerInstallation` (`src/integrations/native/ownership-preflight.ts`). +That probe asks the real service manager: `launchctl print gui//…` on macOS +and `systemctl --user show opencodex-proxy` on Linux, neither of which HOME can +redirect. On a machine with an installed opencodex service the probe returns a +registration that the sandbox plist cannot explain, admission returns +`service-home`, and the round-trip case reads `state: "absent"`. + +Reproduced on `730d898457` by simulating a registered launchd job: 12 pass / 1 fail +with the reported refusal. Hosted runners have no registration, so CI passes. + +The production ownership boundary is correct and unchanged. + +## Change — MODIFY `tests/codex-integration/native-codex-toggle.test.ts` only + +```diff +-import { afterEach, beforeEach, describe, expect, test } from "bun:test"; ++import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; ++import * as serviceManagerProbe from "../../src/service-manager-probe"; ++let restoreServiceManagerProbe = () => {}; + + beforeEach(() => { ++ // The route fixture owns no service, and HOME cannot redirect launchctl/systemctl. ++ // Without this the host's own opencodex registration decides the case. ++ const probe = spyOn(serviceManagerProbe, "inspectServiceManagerInstallation") ++ .mockReturnValue({ kind: "absent" }); ++ restoreServiceManagerProbe = () => probe.mockRestore(); + + afterEach(() => { ++ restoreServiceManagerProbe(); +``` + +The fixture's `service-state.json` is still validated by the real state reader, so +the case keeps exercising the state comparison. + +## Regression + +The existing round-trip case is the regression. Its host dependence is proven by +the scratch reproduction (simulated registered manager: fails on dev, passes with +the spy). A case asserting `inspectServiceManagerInstallation` is called during +enable confirms the stub is on the admission path rather than dead code. + +## Verify + +```sh +FH=$(mktemp -d); env HOME="$FH" USERPROFILE="$FH" CLAUDE_CONFIG_DIR="$FH/.claude" \ + bun test tests/codex-integration/native-codex-toggle.test.ts +``` + + +## Implementation record + +Implemented as written in PR #6834 (branch `codex/n3-codex-toggle-fixture`, commit `c6addbb97e`), +test-only. With a simulated registered service manager the round-trip case fails on dev (12 pass / +1 fail, `service-home` refusal, `state: "absent"`) and passes with the stub (14/14). The new call +assertion fails when the production probe call is bypassed; the spy is restored before fixture +cleanup. + diff --git a/devlog/_fin/261010_cli_path_crossos_probe/000_plan.md b/devlog/_fin/261010_cli_path_crossos_probe/000_plan.md new file mode 100644 index 00000000000..3241fb94acc --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/000_plan.md @@ -0,0 +1,62 @@ +# 000 — Cross-OS probe of the desktop-owned CLI path + +## Objective + +Find out whether the desktop-owned CLI path and install work on `dev` behaves on Linux and Windows the way it was +designed and tested on macOS, and turn every gap into a PR to `dev` or an issue. + +The work under test, all squash-merged on `dev` by 2026-10-09: + +| PR | Commit | Surface | +|---|---|---| +| #6802 | 01acdc8874 | `ocx status`/`doctor`/`resolve` treat a Desktop-supervised runtime as the authority (`src/service/desktop-supervision.mjs`) | +| #6807 | 32abcc363d | package launcher falls back to a validated PATH Bun; lifecycle version-skew notice (`bin/ocx.mjs`, `src/lib/bun-path-runtime.mjs`) | +| #6809 | 63d87f6370 | `service`/`update`/`start`/`stop` refuse to compete with a Desktop-supervised runtime | +| #6812 | 3b9fc865fa | Windows launchers preflight the selected Bun before baking it in | +| #6816 | b89bbfb083 | Desktop owns the `ocx` terminal command on PATH (`desktop/src-tauri/src/cli_command*.rs`) | +| #6818 | 3618baac41 | package launchers hand off to the Desktop `ocx`; `status`/`doctor` report PATH selection | + +The macOS proof is recorded in `devlog/_fin/261009_desktop_owned_path_cli/049_outcome.md`. That note leaves four +items to release QA: packaged click-through of the CLI page, fish on a real login, the Linux deb path, and Windows +terminal pickup after a real registry write. It also records that #6818 deliberately does not hand off on Windows +and that `desktop-supervision.mjs` returns `unsupported` on `win32`, so the #6802/#6809 authority and guards are +inert there. + +No release contains #6816 yet: v2.81.0 was published 2026-10-08, before the merge. On Linux the installer probe runs +a Desktop built from `dev` inside a disposable overlay root on lidge. On Windows a second Desktop cannot run beside +the user's live single-instance 2.77.0, so the installer is exercised at module level (`perform_in` with a real +HKCU write) and the packaged launch is recorded as unverified. + +## Hosts (baseline 2026-10-10) + +| Host | OS | Relevant state | +|---|---|---| +| `ssh lidge` | Ubuntu 24.04.4, x86_64, the login user, bash | `open-codex` 2.61.0 deb owns `/usr/bin/ocx`; Desktop not running; no `~/.opencodex-desktop`; no zsh/fish/docker/xvfb; passwordless sudo; cargo 1.95, webkit2gtk-4.1 2.52.6; bun `/usr/local/bin/bun`; GNOME session on seat0 | +| `ssh mini` | Windows 11 build 26200, the interactive user | OpenCodex Desktop 2.77.0 per-machine MSI at `C:\Program Files\OpenCodex`, running in console session 1 with its `ocx` sidecar; npm `ocx` via nvm4w at `C:\nvm4w\nodejs` which is in both Machine and User Path; bun in `~\.bun\bin`; cargo/rustc; Windows Terminal; no Visual Studio found by vswhere | + +## Constraints + +- All repository work happens in `.tmp/lanes/cli-path-crossos` (branch `codex/cli-path-crossos-probe`); the main + checkout is not touched. Fix branches are cut from `origin/dev` in `.tmp/lanes/` inside this worktree. +- Remote hosts: probe in temp directories and throwaway users where possible, never uninstall an existing tool, + back up before any persistent write, and log every mutation with its exact revert command in + `.tmp/host-changes.md` (worktree scratch, summarised in 040). +- Sol subagents do the host lanes and reviews; `create_thread` is not exposed in this session. +- Authorised: PRs to `dev` and issues. Not authorised: merge, release, version bump, deploy, messaging. + +## Work-phase map (dependency order) + +| Phase | Doc | Depends on | Closes with | +|---|---|---|---| +| wp1 | this file + 010/020/030/040 | — | roadmap reviewed | +| wp2 Linux probe | 010 | wp1 | Linux matrix with lidge evidence | +| wp3 Windows probe | 020 | wp1 | Windows matrix with mini evidence | +| wp4 fixes, issues, closeout | 040 (030 is the release-QA checklist both probes fill) | wp2, wp3 | PRs with exact-head CI, issues, reverts | + +wp2 and wp3 touch different hosts and no shared files, so they run in parallel lanes. + +## Outcome vocabulary + +Each probe row ends as **works** (observed on the host), **broken** (observed failure, with the command and output), +**by design** (behaviour matches a documented decision, e.g. Windows `windows-path-only`), or **unverified** (with +the reason it could not be observed). diff --git a/devlog/_fin/261010_cli_path_crossos_probe/001_host_baseline.md b/devlog/_fin/261010_cli_path_crossos_probe/001_host_baseline.md new file mode 100644 index 00000000000..3eb10a93536 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/001_host_baseline.md @@ -0,0 +1,41 @@ +# 001 — Host baseline and change-log protocol (2026-10-10) + +Captured read-only before any probe mutation. Later rows in 030 compare against this. + +## lidge (Ubuntu 24.04.4, kernel 7.0.0-30, x86_64) + +- `dpkg -l open-codex` → `ii open-codex 2.61.0 amd64 OpenCodex desktop shell`; `dpkg -S /usr/bin/ocx` → `open-codex`; + `ocx --version` → `opencodex 2.61.0`. Desktop not running; `~/.opencodex-desktop` absent. +- Shell `/bin/bash`; `~/.bashrc` and `~/.profile` exist, `~/.bash_profile` and `~/.zshrc` do not; no fish, zsh, + docker or Xvfb. `unshare` and `setpriv` present; passwordless sudo. +- Bun `/usr/local/bin/bun` 1.3.14 (the launcher pins 1.4.2); Node `/usr/bin/node`; cargo/rustc 1.95 under + `~/.cargo/bin` (not on the non-interactive SSH PATH); webkit2gtk-4.1 / javascriptcoregtk-4.1 2.52.6, libsoup-3.0 3.4.4. +- GNOME session for `` on seat0/tty2. + +## mini (Windows 11 build 26200, the interactive user) + +- OpenCodex Desktop 2.77.0, per-machine MSI, `C:\Program Files\OpenCodex\`, running in console session 1 + (`opencodex-desktop` PID 22680, `ocx` sidecar PID 33476); `~\.opencodex-desktop` absent. +- npm `ocx` 2.58.0 via nvm4w: `C:\nvm4w\nodejs\ocx{,.cmd,.ps1}`; `C:\nvm4w\nodejs` is in both Machine Path and + User Path; HKCU Path is `REG_EXPAND_SZ`. +- Bun `%USERPROFILE%\.bun\bin\bun.exe`; cargo/rustc stable `x86_64-pc-windows-msvc` (links despite `vswhere -latest` + listing nothing); Windows Terminal `wt.exe`; PowerShell 7 in Path. + +## Temp clones + +Both hosts carry a clone of `d17a9f2239` with CI's empty sidecar/resource stubs (ci.yml:1567-1573): +`/tmp/ocx-probe-261010/repo` (lidge) and `%TEMP%\ocx-probe-261010\repo` (mini). + +## Change-log protocol + +Every remote mutation is appended to the worktree scratch file `.tmp/host-changes.md` (gitignored) BEFORE it is +made: time (KST), host, exact command, exact revert command, and the check that proves the revert. 040's closeout +copies the final table with each row marked reverted or kept (with reason). Secrets, tokens and request bodies never +enter the log. + +## First evidence + +- lidge `cargo test --manifest-path desktop/src-tauri/Cargo.toml cli_command`: run 1 `45 passed; 1 failed; 2 ignored` + (`final_save_before_journal_deletion_is_cleaned_on_reopen`, `Err("lock-busy")` at `cli_command_record.rs:1114`); + runs 2-4 `46 passed; 0 failed; 2 ignored`; isolated test 5/5 pass. +- mini, same command: `27 passed; 0 failed; 0 ignored`. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/010_linux_probe.md b/devlog/_fin/261010_cli_path_crossos_probe/010_linux_probe.md new file mode 100644 index 00000000000..44bcccc154a --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/010_linux_probe.md @@ -0,0 +1,67 @@ +# 010 — wp2: Linux probe on `ssh lidge` + +Source under test: a temp clone of `d17a9f2239` at `/tmp/ocx-probe-261010/repo` on lidge. Every host mutation is +appended to the worktree's `.tmp/host-changes.md` with its revert before it is made. + +## Code facts the probe relies on (read 2026-10-10) + +- Install kind: `desktop/src-tauri/src/cli_command_posix.rs:147` rejects debug builds (`development-launch`), + AppImage env or `/tmp/.mount_` paths (`temporary-bundle`) and any executable whose canonical parent is not + `/usr/bin` (`unpackaged-launch`); a qualifying launch needs an executable `/usr/bin/ocx` and records `linux-deb`. + dpkg is never consulted. +- Owned paths: `~/.opencodex-desktop/{bin/ocx (0700), path.sh (0600), cli.json, cli.lock, journal/, backups/}`. +- Managed blocks (`# >>> OpenCodex Desktop ocx PATH v1 >>>`): `.bashrc` plus the FIRST existing of + `.bash_profile`/`.bash_login`/`.profile` (none created; absent → `login-file-absent`, partial); `.zshrc` and + `.zlogin` under `ZDOTDIR` or HOME; `${XDG_CONFIG_HOME:-~/.config}/fish/config.fish` wrapped in + `if status is-interactive` (`cli_command_posix.rs:97-132`). +- The ignored `real_shell_selects_desktop_shim_on_temp_home` test covers bash and zsh only, never fish. +- Launcher handoff (`src/lib/desktop-cli-handoff.mjs`): escape hatch and recursion guard `OCX_NO_DESKTOP_HANDOFF=1`; + update/uninstall/remove/internal commands are excluded; record must be 0700 dir / 0600 file owned by the euid. +- PATH-Bun fallback (`src/lib/bun-path-runtime.mjs`) needs Bun ≥ the pinned 1.4.2; lidge's `/usr/local/bin/bun` + is 1.3.14 and must be rejected. + +## Probe steps + +| # | Step | Command sketch | Expected | +|---|---|---|---| +| L1 | Rust unit tests | `cargo test --manifest-path desktop/src-tauri/Cargo.toml cli_command` with CI's empty sidecar/resource stubs (ci.yml:1567-1573), repeated ×5 | all pass (first run: 1 `lock-busy` flake, see F1) | +| L2 | Ignored real-shell test | `... cli_command_posix::tests::real_shell_selects_desktop_shim_on_temp_home -- --ignored --exact --nocapture` | bash passes; zsh skipped (absent) | +| L3 | Build dev deb | `bun install` (root, gui, desktop) → `bun run build:gui` → `bun desktop/scripts/prepare-sidecar.ts --target x86_64-unknown-linux-gnu` → in `desktop/`: `bunx tauri build --ci --bundles deb --config '{"bundle":{"createUpdaterArtifacts":false}}'` | `.deb` under `src-tauri/target/release/bundle/deb/` | +| L4 | Throwaway user | `sudo useradd -m -s /opt/ocx-probe/fish/bin/fish ocxprobe` after unpacking `fish-4.9.3-linux-x86_64.tar.xz` into `/opt/ocx-probe/fish`; give it `.bashrc` and `.profile` copied from `/etc/skel` | user exists, `getent passwd ocxprobe` shows fish | +| L5 | Install dev deb | download v2.61.0 deb for revert first; `sudo apt install ./OpenCodex-*.deb` | `/usr/bin/opencodex-desktop` + `/usr/bin/ocx` from dev | +| L6 | Desktop reconcile as ocxprobe | run `/usr/bin/opencodex-desktop` as ocxprobe under a virtual display (`xvfb-run` installed and logged, or the user's Wayland/X session if Xvfb is refused) long enough for `reconcile_on_launch`, then stop it | `~ocxprobe/.opencodex-desktop/cli.json` enabled, kind `linux-deb`; blocks in `.bashrc`, `.profile`, `.zshrc`, `.zlogin`, `config.fish` | +| L7 | Real login shells | `sudo -iu ocxprobe` (fish login, interactive via `script -qc`), `fish -l -c 'command -v ocx'`, `bash -l -i -c`, `bash -lc`, `env -i ... bash --login`, `ssh ocxprobe@localhost` if keys allow | interactive fish/bash → `~/.opencodex-desktop/bin/ocx`; non-interactive fish → `/usr/bin/ocx` (guard) | +| L8 | Package launcher handoff | as ocxprobe: `npm i -g` of a `npm pack` tarball into `~/.npm-global`, `bun link` from the clone, and a linuxbrew-style `~/.linuxbrew/bin/ocx` symlink; run each `--version`/`status`; repeat with `OCX_NO_DESKTOP_HANDOFF=1` | handoff to the record target; escape hatch runs the package | +| L9 | Diagnostics | `ocx status`, `status --json`, `doctor`, `resolve --json` with and without the Desktop running | `ocx command:` line names Desktop first/not first; supervisor line only with Desktop live | +| L10 | #6809 guards | with dev Desktop running as ocxprobe: package `ocx start`, `stop`, `service install`, `update --check`-style dry paths | refusals naming Desktop | +| L11 | Bun fallback | temp package copy with bundled Bun removed; PATH first = Bun 1.4.2 in a temp dir; then 1.3.14 | 1.4.2: `using PATH Bun` notice; 1.3.14 rejected with Desktop CLI named | +| L12 | Opt-out and revert | Desktop "Remove" / disabled reconcile as ocxprobe; then `sudo userdel -r ocxprobe`, `sudo apt install ./OpenCodex-2.61.0-linux-amd64.deb` (allow downgrade), remove `/opt/ocx-probe`, `/tmp/ocx-probe-261010` | blocks removed except user-modified ones; host back at 2.61.0 | + +## Outcomes + +Each row is recorded in 030's matrix as works / broken / by design / unverified with the command and output tail. + +## Reflection (same architect, Dewey 01a1225a-9e23): MISALIGNED → dispositions + +These replace the matching rows above. + +- **L1/F1:** the first-run `lock-busy` failure stays recorded as evidence. The forked-child cause is a hypothesis. + Before any fix, B reproduces under a loop (`for i in $(seq 30)`), captures `RUST_BACKTRACE=1` and the errno from + `flock`, and identifies the holder with `/proc/locks` plus `ls -l /proc/*/fd` matching the lock inode during a + failure (an instrumented copy of the test may sleep on failure to allow the snapshot). The fix shape is chosen + only from that evidence. +- **L7:** positive fish case is `fish -l -i -c 'command -v ocx'` and an interactive login via `script -qc 'sudo -iu ocxprobe' /dev/null`; + the negative case is `env -i HOME=/home/ocxprobe PATH=/usr/bin:/bin /opt/ocx-probe/fish/bin/fish -l -c 'command -v ocx'`. + Bash: `env -i HOME=/home/ocxprobe PATH=/usr/bin:/bin bash --login -c 'command -v ocx'` and with `-i`. +- **L10:** no live `update`. The guard probe runs `start`, `stop` and `service install` from the package as ocxprobe + only while the dev Desktop runs as ocxprobe with ocxprobe's own `~/.opencodex`, and the focused existing tests + (`tests/update/update-desktop-owner.test.ts`, `tests/service/service-ownership-handover.test.ts`, + `tests/cli/cli-stop-json.test.ts`) run on lidge for `update`. +- **L11:** set `OCX_NO_DESKTOP_HANDOFF=1`; remove the bundled Bun and the installer from the temp package copy; the + 1.3.14 expectation is "rejected, no Desktop CLI named" because `findDesktopCli` returns null off macOS (candidate + finding F4). +- **L8/L12:** npm-global uses `npm_config_prefix=/home/ocxprobe/.npm-global`. Before L5, download the v2.61.0 deb to + `/opt/ocx-probe/revert/`, verify it against its `.sha256`, and record `dpkg-deb -f Package Version`; L12 + restores with `sudo apt install --allow-downgrades /opt/ocx-probe/revert/OpenCodex-2.61.0-linux-amd64.deb`. + +See the audit dispositions at the end of 020_windows_probe.md; items 1 and 5 replace L4, L5, L7 and L12 host steps. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/011_wp2_execution.md b/devlog/_fin/261010_cli_path_crossos_probe/011_wp2_execution.md new file mode 100644 index 00000000000..e957c082d16 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/011_wp2_execution.md @@ -0,0 +1,101 @@ +# 011 — wp2 execution: three Linux lanes on lidge + +Re-verified against `origin/dev` 93ed1a40b4: the two commits after `d17a9f2239` (#6846, #6848) touch no file under +test, so 010 and its dispositions stand. Architect proposal (Dewey, 01a1225a-9e23) D1-D6 accepted as below. + +## Host hazards found in P + +- lidge runs the user's own npm `ocx` proxy (PID 3280942) on `127.0.0.1:10100`. No probe may reach, stop or replace + it: every Desktop and `ocx` probe runs in a loopback-only network namespace, and each lane re-checks that PID and + the port listener before and after. +- `/`, `/var/tmp`, `/tmp` and `/home` share one ext4 filesystem, so an overlay upper directory on `/var/tmp` overlaps + `lowerdir=/`; upper and work go on a tmpfs mounted inside the private mount namespace (D3). +- 's umask is 002; the PATH-Bun fallback skips group-writable Bun files or directories (D6). + +## Decisions + +| ID | Decision | Disposition | +|---|---|---| +| D1 | Live Desktop checks (L9 with Desktop, L10 refusals) run in lane A, the only place a dev Desktop exists; lane B uses fixture records | accepted | +| D2 | Each lane has its own clone and `BUN_INSTALL`, `BUN_INSTALL_CACHE_DIR`, `npm_config_cache` under `/tmp/ocx-probe-261010/`; only `~/.cargo` is shared (cargo locks it) | accepted | +| D3 | Overlay of `/` with tmpfs upper/work inside `sudo setsid -f unshare --mount --pid --fork --propagation private`, fresh `proc`, rbind `/dev` `/sys`, tmpfs over `/tmp` `/run` `~`; enter with `nsenter -t -m -p -r -w` | accepted | +| D4 | Loopback-only network namespace for every Desktop/`ocx` probe; Desktop as `ocxprobe` under `dbus-run-session -- xvfb-run -a` with `WEBKIT_DISABLE_DMABUF_RENDERER=1`; apt inside the overlay with a `policy-rc.d` returning 101 | accepted | +| D5 | `bun install` with the host Bun, then the repo's `node_modules/bun/bin/bun.exe` (1.4.2) for `build:gui`, `prepare-sidecar` and `x tauri build` | accepted | +| D6 | L11 temp Bun directory and binary `chmod 755` | accepted | + +## Lanes + +| Lane | Rows | Workspace | Returns | +|---|---|---|---| +| A — packaged deb | L3, L5-L7, live L9/L10, L12; 020 audit items 1 and 5 | clone `/tmp/ocx-probe-261010/a`; overlay namespace | namespace PIDs and mounts; `dpkg -s open-codex` inside vs outside; `cli.json`; each rc diff; LOGIN/INTERACTIVE/`command -v ocx` per shell mode; status/doctor/resolve with Desktop live; guard refusal text; Desktop log tail; revert checks | +| B — launcher and diagnostics | L8, L9 without Desktop, L10 focused tests, L11, F4 | clone `/tmp/ocx-probe-261010/b`; temp HOMEs on the real root; probes inside a loopback netns as with `env -i` and a PATH without `~/.local/bin` | `--version` through npm-global (`npm_config_prefix`), bun-link and a linuxbrew-shaped path, with and without `OCX_NO_DESKTOP_HANDOFF=1`; record-state table (missing, disabled, ready, unsafe mode, pending, oversize, target missing, target is self) with status/doctor lines; resolve output; L11 notice and the 1.3.14 failure text; focused test results with the repo Bun | +| C — F1 lock-busy | L1, L2, F1 | existing `/tmp/ocx-probe-261010/repo` | pass/fail counts for 30 parallel and 30 `--test-threads=1` runs with load average; on failure errno and holder process from a `/proc/*/fd` inode scan during an instrumented 3 s sleep; cause verdict; L2 ignored real-shell result | + +## Stop conditions + +- Any lane: the host proxy PID or the `:10100` listener changes, `dpkg -s open-codex` stops reporting 2.61.0, or + `/etc/passwd` or a file in `~` changes → stop and revert. +- A: overlay mount fails → stop and report; Desktop not up in 120 s after one software-rendering retry → row + unverified per audit item 1. +- B: any probe reaches port 10100 → stop. +- C: 60 runs without a failure → "not reproduced", no fix PR for F1 (the first-run failure stays recorded). +- Revert: kill the namespace holder, require `lsns -t mnt` and `findmnt` free of `ocx-probe`, then + `sudo rm -rf /var/tmp/ocx-probe`; lanes B/C remove their `/tmp/ocx-probe-261010/` and any netns they created. + +Every mutation is logged in the worktree `.tmp/host-changes.md` (via the coordinator) before it happens. + +## Reflection (Dewey, same architect): MISALIGNED → dispositions + +1. The namespace holder runs `exec chroot $M sleep infinity`; every later step enters with + `sudo nsenter -t -m -p -r -w`, so the root is the overlay (D3 corrected). +2. Before the holder starts, `sudo mkdir -p /var/tmp/ocx-probe /var/tmp/ocx-probe-evidence` is logged as a host change. + Inside the namespace, `/var/tmp/ocx-probe-evidence` is bind-mounted to `$M/evidence` so lane A's results survive the + tmpfs teardown, and the overlay's dangling `/etc/resolv.conf` symlink is replaced by the host file's contents. +3. The network namespace (`ip netns add ocxprobe261010`, loopback up) is created after apt and the fish download, kept + alive by its own named namespace, and every Desktop, status, doctor, resolve and guard probe runs in that one + namespace (`ip netns exec ocxprobe261010 nsenter ...` or the reverse order, whichever works with the chroot). + Revert: `sudo ip netns delete ocxprobe261010`. +4. Lane B uses the pinned Bun for `bun install` and `build:gui` before `npm pack` (`prepack` → `prepare:package`). +5. Stop condition watches named paths only: `~/.bashrc`, `~/.profile` (sha256 recorded first), `~/.opencodex` (mtime of + its top-level files), absence of `~/.opencodex-desktop`, `~/.local/lib/node_modules/@bitkyc08`; `~/.cargo` is shared + by design. +6. Revert check: record the holder PID and `readlink /proc//ns/mnt` at start; revert requires the PID gone and that + inode absent from `sudo lsns -t mnt` before `sudo rm -rf /var/tmp/ocx-probe`. + +## Audit (independent Sol, 01a1226f-82b1): FAIL → dispositions + +These supersede earlier rows where they conflict. + +1. **Fixed entry and gate.** Lane A creates the netns first on the host (`sudo ip netns add ocxprobe-a`, + `sudo ip -n ocxprobe-a link set lo up`; no other interfaces), and the mount/PID holder is started *inside* it: + `sudo ip netns exec ocxprobe-a setsid -f unshare --mount --pid --fork --propagation private sh -c '; exec chroot $M sleep infinity'`. + Network access for apt and the fish tarball is done before that, by downloading on the host into + `/var/tmp/ocx-probe-evidence/in/` (debs via `apt-get download` of xvfb and its deps, fish tarball, the dev deb) and + installing inside the overlay from those files with `dpkg -i` only. Every later command enters with exactly + `sudo nsenter -t -m -p -n -r -w ` and is preceded by a gate script that aborts unless: + `readlink /proc/self/ns/mnt` and `/proc/self/ns/pid` equal the holder's recorded inodes, `/proc/self/ns/net` + equals `ocxprobe-a`'s inode, `ip -o link` lists only `lo`, and `test -f /evidence/.overlay-marker` (a file written + only into the overlay at setup). Lane B uses its own `ocxprobe-b` netns with the same gate minus the overlay checks. +2. **Deb route.** The dev deb is copied from lane A's host clone to `/var/tmp/ocx-probe-evidence/in/` with its sha256 + recorded, then installed inside the overlay from `/evidence/in/`. Before and after: host `dpkg -s open-codex` + reports 2.61.0 and `sha256sum /usr/bin/ocx` on the real root is unchanged. +3. **Evidence and rollback.** Evidence is copied back with `rsync -a lidge:/var/tmp/ocx-probe-evidence/out/ + /.tmp/evidence/lidge-a/`; then the holder is killed, its mount-ns inode must be absent from `sudo lsns -t mnt`, + `sudo ip netns delete ocxprobe-a`, `sudo ip netns delete ocxprobe-b`, + `sudo rm -rf /var/tmp/ocx-probe /var/tmp/ocx-probe-evidence`, and + `ls -d /var/tmp/ocx-probe* /tmp/ocx-probe-261010` must fail; `ip netns list` must not show either name. + A rerun must first confirm that `/var/tmp/ocx-probe` and `/var/tmp/ocx-probe-evidence` do not exist (or use fresh + `mktemp -d` paths): `mkdir -p` accepts existing directories, and this cleanup removes everything under the fixed paths. +4. **Supervision gate.** Live L9/L10 coverage is claimed only if `resolve --json` (or a direct + `inspectDesktopSupervision()` call) inside the namespace reports `kind: "desktop"` for the Desktop's child; + otherwise those rows are recorded unverified with the inspector's actual result. + +5. **Root proof.** The gate requires `stat -f -c %T /` = `overlayfs` and `test -f /.ocxprobe-overlay-root`, a marker + written to `$M/.ocxprobe-overlay-root` after the overlay mount (it lives only in the tmpfs upper layer); the + `/evidence` marker is dropped. +6. **Per-path ownership and cleanup.** Each lane removes only what it owns, after it finishes, and checks each path + separately with `test ! -e `: + - lane A: `/tmp/ocx-probe-261010/a`, netns `ocxprobe-a`, holder ns, `/var/tmp/ocx-probe`; + - lane B: `/tmp/ocx-probe-261010/b`, netns `ocxprobe-b`; + - lane C: `/tmp/ocx-probe-261010/repo` and `/tmp/ocx-probe-261010/c`; + - coordinator, after all three: `/var/tmp/ocx-probe-evidence` (after rsync) and the now-empty `/tmp/ocx-probe-261010`. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/019_wp2_outcome.md b/devlog/_fin/261010_cli_path_crossos_probe/019_wp2_outcome.md new file mode 100644 index 00000000000..9b8c1d0bdb7 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/019_wp2_outcome.md @@ -0,0 +1,27 @@ +# 019 — wp2 outcome: Linux on lidge + +Source `d17a9f2239` (package 2.82.0). Three Sol lanes ran in parallel under 011; every host mutation was logged and +reverted. Raw evidence stays in the worktree scratch `.tmp/evidence/lidge-{a,b,c,shared}/` (not committed). + +| Row | Result | Evidence | +|---|---|---| +| L1 Rust `cli_command` tests | **works** — 60/60 runs `46 passed; 0 failed; 2 ignored` (30 parallel, 30 `--test-threads=1`, load 0.04-0.66) | lane C `results.txt` | +| F1 first-run `lock-busy` | **unverified** — not reproduced in 60 runs; the single first-run failure (`final_save_before_journal_deletion_is_cleaned_on_reopen`, 001) stays unexplained; no fix PR per the 011 stop rule | lane C | +| L2 ignored real-shell test | **broken (test)** — exit 101, `bash: expected exactly two stdout lines`. Ubuntu's `/etc/bash.bashrc:44-53` prints the sudo hint to stdout for a sudo-group user without `~/.hushlogin`; the shell itself resolved `/usr/bin/ocx` then `npm-ocx` correctly. Test assumption at `cli_command_posix.rs:1063-1066` → F5 | lane C `l2-real-shell.log`, `bash-diagnostic.log` | +| L3 dev deb build | **works** — pinned Bun 1.4.2 `build:gui` → `prepare-sidecar` → `tauri build --bundles deb` produced `OpenCodex_2.82.0_amd64.deb`, whose sha256 was recorded when it was staged and matched at install | lane A `02_build.sh`, `07_gate_deb.log`, `08_install.log`; the raw build log was deleted with the clone before it was copied, so `02_build_observed_tail.md` is a transcription | +| L5/L6 deb install + reconcile | **works** — inside the overlay `dpkg -i` → 2.82.0 (host stayed 2.61.0); Desktop under `dbus-run-session -- xvfb-run -a` wrote an enabled `cli.json` with a bundle (the `kind` value itself was not printed; on Linux only `posix::stable_bundle` accepting a `/usr/bin` launch produces one, and it records `linux-deb`), `status --json` `cliCommand.configured: true`, shim 0700, `path.sh` 0600, and managed blocks in `.bashrc`, `.profile`, `.zshrc`, `.zlogin`, `.config/fish/config.fish` matching the generated text exactly | lane A `08_install.log`, `10_reconcile_readout.log`, `13b_diagnostics.log`, `15_rc_exact.log` | +| L7 real login shells (release QA: fish) | **works** — real fish login via `script -qec 'su - ocxprobe'`: login 0, interactive 0, `command -v ocx` → `~/.opencodex-desktop/bin/ocx`; `fish -l -i -c` same; clean-PATH `fish -l -c` interactive 1 → `/usr/bin/ocx` (**by design**, `if status is-interactive` guard); bash interactive login and `bash -lc` → shim | lane A `11_fish_matrix.log`, `12_bash_matrix.log` | +| L8 package launcher handoff | **works** — npm-global (`npm_config_prefix`), `bun link` and a linuxbrew-shaped symlink all hand off to the record target with a ready record; `OCX_NO_DESKTOP_HANDOFF=1` runs the package (`opencodex 2.82.0`); unsafe mode, symlinked record, pending and oversize records are refused with explicit errors | lane B `matrix.log` | +| L9 diagnostics | **works** — without Desktop: `status`/`--json` name the npm launcher first, `.cliCommand.desktopFirstOnPath=false` with `path-first-not-desktop`, doctor shows it, `resolve --json` `runtime-absent`; with the dev Desktop live in the overlay: Desktop first on PATH, `startupSource: live`, `resolve --json` `supervisor.kind: "desktop"` | lane B `matrix.log`; lane A `13b_diagnostics.log` | +| L10 refuse-to-compete (#6809) | **works** — with `supervisor.kind=desktop`: `ocx start` refuses, `ocx service install` refuses and leaves supervision intact; `ocx stop` stops the child and warns the Desktop may restart it (**by design**, `src/cli/desktop-runtime-guidance.ts:27`, `tests/cli/cli-stop-json.test.ts:279`; 010's "refuse" expectation was wrong). Focused tests: 517 pass / 4 skip / 0 fail across the 11 files | lane A `14b_guards.log`, `24_service_guard.log`; lane B `tests-final.log` | +| L11 PATH-Bun fallback (#6807) | **works** — bundled Bun and installer removed, Bun 1.4.2 first on PATH: `opencodex: using PATH Bun 1.4.2.` and `opencodex 2.82.0`; with only 1.3.14: `Bun binary missing after install attempt.` (exit 1) | lane B `fallback.log` | +| F4 Linux failure text names no Desktop CLI | **by design today** — `findDesktopCli()` is macOS-only (`src/lib/bun-path-runtime.mjs:80`), so a broken package launcher on a deb host does not point at `/usr/bin/ocx`; naming it safely needs a check that `/usr/bin/ocx` is the deb's binary and not an npm `--prefix /usr` symlink, so it is a follow-up, not a fix here | lane B | +| L12 opt-out | **unverified** — the CLI page's Remove/disable action is not reachable without a GUI operator; cleanup of the probe itself verified | lane A | +| Packaged CLI page click-through | **unverified** — same reason | — | + +Host state after wp2 (coordinator check): `/var/tmp/ocx-probe*`, `/tmp/ocx-probe-261010`, `~/.opencodex-desktop` absent; +no `ocxprobe*` netns; no `ocxprobe` user; `open-codex` 2.61.0; user proxy PID 3280942 still on `127.0.0.1:10100`. + +Process note: the first dispatch of lanes A and B was tagged as reviewers by codexclaw's keyword role inference +(packet text contained "audit"/"verify") and refused to execute; they were redispatched with a `CXC-ROLE: explorer` +header. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/020_windows_probe.md b/devlog/_fin/261010_cli_path_crossos_probe/020_windows_probe.md new file mode 100644 index 00000000000..58f2b12188b --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/020_windows_probe.md @@ -0,0 +1,107 @@ +# 020 — wp3: Windows probe on `ssh mini` + +Source under test: a temp clone of `d17a9f2239` at `%TEMP%\ocx-probe-261010\repo` on mini. The live Desktop 2.77.0 +(per-machine MSI, `C:\Program Files\OpenCodex`, PID 22680, session 1) and nvm4w npm `ocx` 2.58.0 are user tools and +are not reinstalled, stopped or upgraded. + +## Code facts (read 2026-10-10) + +- `cli_command_windows.rs:693` `stable_bundle` rejects debug launches and paths containing `target`, `temp`, `tmp`; + needs a regular non-symlink sibling `ocx.exe`. Any install root qualifies, including Program Files. +- `prepend` (600) puts the install directory first in `HKCU\Environment\Path`, preserving raw text and REG_SZ vs + REG_EXPAND_SZ; `machine_conflict` (805) flags any Machine Path dir holding `ocx.exe/.com/.cmd/.bat`; `broadcast` + (848) sends `WM_SETTINGCHANGE`/`Environment` with a 1 s per-recipient timeout; `notifyPending` persists failure. +- No test writes the real HKCU; the Windows Path tests are pure transforms or use an in-memory registry seam. +- `src/lib/desktop-cli-handoff.mjs:19` returns `windows-path-only`; `src/service/desktop-supervision.mjs:75` returns + `unsupported` for anything but darwin/linux, so #6802 authority and #6809 refusals never engage on Windows. + +## Probe steps + +| # | Step | Expected | +|---|---|---| +| W1 | `cargo test --manifest-path desktop\src-tauri\Cargo.toml cli_command` with CI stubs, ×3 | pass (first run 27/27) | +| W2 | Baseline: `reg query HKCU\Environment /v Path` (type), `reg export HKCU\Environment .reg /y`, `Get-Command ocx -All`, `where ocx` | backup file recorded in host log | +| W3 | Real HKCU write through the real module: a throwaway `#[ignore]` test added only in the temp clone that runs the Windows `plan` → `apply_change` → `notify` path for a staging dir `%USERPROFILE%\ocx-probe-desktop\` (no temp/tmp in path) holding a copy of `C:\Program Files\OpenCodex\ocx.exe` | HKCU Path gains the staging dir first, type preserved; issues include `machine-path-conflict` | +| W4 | New terminal pickup: (a) scheduled task `/IT` in session 1 running `cmd /c "echo %PATH% & where ocx" > out.txt`; (b) `explorer.exe ` launched via an /IT task so Explorer's refreshed environment is used; (c) `wt new-tab --reloadEnvironment` | staging dir present in PATH of each new process; `where ocx` still lists `C:\nvm4w\nodejs\ocx.cmd` first (machine conflict) | +| W5 | Counter-probe without the machine conflict: same harness, but compute selection with a PATH where Machine entries lack ocx (simulated by the module's own selection on a copy, not by editing Machine Path) | Desktop dir would win; documents that the conflict is the only blocker | +| W6 | Revert W3 through the module's removal path, then compare `reg query` with the backup | HKCU Path byte-identical to baseline; otherwise `reg import` after checking for unrelated edits | +| W7 | Diagnostics with new code: from the clone, `bun src\cli\index.ts status --json`, `doctor`, `resolve --json` against the live Desktop runtime (read-only), with and without a hand-made 0600-equivalent `cli.json` record in a temp USERPROFILE | `desktopSupervision: unsupported`; status recommends service actions despite Desktop supervising (gap); `packageHandoff=disabled-on-windows`; `path-first-not-desktop` | +| W8 | #6809 guards in isolation: `OPENCODEX_HOME`, `CODEX_HOME` in temp, free port; start a runtime from the Desktop's `ocx.exe` copy as a fake "Desktop child", then package `start`/`stop` from the clone against that isolated home | no refusal on Windows (gap evidence); never against the live home | +| W9 | #6812 Bun preflight: focused tests `tests/lib/bun-runtime-preflight.test.ts`, `tests/service/service-runtime-preflight.test.ts`, `tests/codex-integration/codex-shim-runtime-preflight.test.ts` on mini; plus `OPENCODEX_BUN_PATH` pointing at a non-Bun exe in temp with an isolated home | tests pass; bogus Bun rejected before baking | +| W10 | Launcher Bun fallback (#6807) on Windows: temp package copy, bundled Bun absent, PATH Bun 1.4.2 vs the user's `~\.bun\bin\bun.exe` version | 1.4.2 accepted with notice | + +Do not run against the live install: package `ocx start`, `stop`, `service install/repair/start/restart/stop`, +`update`, `doctor --reclaim`. + +## Reflection (same architect, Boyle 01a1225a-9ed0): MISALIGNED → dispositions + +These replace the matching rows above. + +- **W3/W6 contract:** the harness proves the real registry write, the broadcast and the persisted debt, so it goes + through the same entry the Desktop uses: `Store::transact` with the Windows plan for a bundle produced by + `stable_bundle` on the staging directory (copied `opencodex-desktop.exe` + sibling `ocx.exe`), with HOME/USERPROFILE + pointed at a temp record root for the store. If `stable_bundle` refuses the staging copy, that refusal is recorded + and the bundle is built by hand for the write probe only. Restoration runs in a `finally`-style guard (Drop) that + calls the removal path; W6 compares raw bytes and value type from `RegQueryValueExW` + (`Get-Item HKCU:\Environment` `.GetValue('Path',$null,'DoNotExpandEnvironmentNames')` + `GetValueKind`) against + the values saved in W2, and falls back to setting the saved raw value and type, never `reg import` of the whole key. +- **W4:** the scheduled task runs a pre-written `probe.cmd` (PATH evaluated at run time, not creation), writing + `%PATH%`, `where ocx` and its own parent PID/image/session (via PowerShell `Get-CimInstance Win32_Process`). + The Explorer variant launches `explorer.exe probe.cmd` from an /IT task and records the parent chain; `wt new-tab + --reloadEnvironment` runs from an /IT task in session 1. SSH `cmd` is reported only as the stale control. +- **W5:** labelled simulated: a pure PATH-order computation over Machine then User entries after the write. +- **W7:** expectations use real fields: `status --json` `startupSource` and `cliCommand`; `resolve --json` + `supervisor.kind="unsupported"` when live. A hand-made record is labelled synthetic. Service-action + recommendations are recorded only if status reports the runtime unhealthy. +- **W8:** no copied sidecar. Evidence is the direct inspector result on win32 (`inspectDesktopSupervision()` → + `unsupported` while the real Desktop child is live) and the focused guard tests on mini; any lifecycle simulation is + isolated from client homes too. F2 separates the supervision gap from the deliberate PATH-only handoff choice. +- **W9:** the shim test is `tests/codex-integration/codex-shim-runtime-preflight.test.ts`. W10 first reads the pinned + Bun policy in `src/lib/bun-path-runtime.mjs:67`. + + +## Audit (independent Sol, 01a12262-abb4): FAIL → dispositions + +These supersede the earlier rows and the reflection dispositions where they conflict. + +1. **Disposable Linux root (replaces L4, L5, L12 host steps).** The dev deb is never installed on lidge's real root. + B builds a private overlay of `/` and does everything that needs `/usr/bin` inside it: + `sudo unshare --mount --pid --fork --propagation private` → `mount -t overlay overlay -o lowerdir=/,upperdir=/var/tmp/ocx-probe/upper,workdir=/var/tmp/ocx-probe/work /var/tmp/ocx-probe/merged` + → mount fresh `proc`, bind `/dev`, `/dev/pts`, `/sys`, a tmpfs `/tmp`, and copy `/etc/resolv.conf` → `chroot`. + Inside: `dpkg -i` the dev deb (replacing 2.61.0 only in the overlay), `apt-get install -y xvfb dbus-x11` if needed, + unpack portable fish 4.9.3 to `/opt/fish`, `useradd -m -s /opt/fish/bin/fish ocxprobe` with `/etc/skel` files. + The host dpkg database, `/usr/bin/ocx`, `/etc/passwd` and real HOMEs are never written. Revert: exit the + namespace (its mounts vanish with it) and `sudo rm -rf /var/tmp/ocx-probe`. Verification of the revert: + `dpkg -s open-codex | grep Version` = 2.61.0, `getent passwd ocxprobe` empty, `mount | grep ocx-probe` empty. + If the overlay cannot run the Desktop (WebKit, dbus, display), the deb row is recorded as unverified with the + error and the install-kind evidence falls back to the overlay's `dpkg -i` plus a direct call of + `stable_bundle` through the Desktop's `ocx` path checks. +2. **Windows integrated launch.** The Desktop uses `tauri-plugin-single-instance` (`desktop/src-tauri/src/lib.rs:298`), + so a second dev Desktop on mini would hand its arguments to the live 2.77.0 instance; launching one is not safe or + meaningful. The Windows matrix therefore states "packaged Desktop launch: unverified (single instance with the + user's live Desktop)" and records module-level evidence: the throwaway harness calls `perform_in` + (`cli_command.rs:165`), the same core `perform` and `reconcile_on_launch` use, with `observe = stable_bundle(, false, )`, a `Store` under a temp USERPROFILE-like root, and `Action::Reconcile`. + Only `reconcile_on_launch`'s Tauri scheduling and `perform`'s home lookup stay outside the harness. +3. **Windows Terminal observation inside the tab.** The /IT task runs + `wt.exe -w new new-tab --reloadEnvironment cmd /c %USERPROFILE%\ocx-probe-desktop\probe.cmd wt`; `probe.cmd` + writes `%PATH%`, `where ocx` and a PowerShell dump of its own process, parent and grandparent (name, PID, + SessionId) to `probe-wt.txt`, then exits. Controls, each with its own output file: `explorer.exe probe.cmd` from + an /IT task (parent must be the logon explorer.exe), a plain /IT task running `probe.cmd` (Task Scheduler builds a + fresh environment), and SSH `cmd` (stale). Before the write, the same four probes run once as the baseline. +4. **Windows revert contract.** Order: (a) W2 saves the raw Path value and kind via + `.GetValue('Path',$null,'DoNotExpandEnvironmentNames')`/`GetValueKind('Path')` to a file, plus `reg export` as a + second copy; (b) after the probes the harness calls `perform_in(..., Action::Remove, ...)`, which saves the record + off before removal (`cli_command.rs:179-201`) and broadcasts; (c) read the value again: identical raw text and + kind → done; otherwise diff it against (a) and, if the only difference is the owned entry, set the saved raw value + with the saved kind and broadcast `WM_SETTINGCHANGE` (PowerShell `SendMessageTimeout` P/Invoke); any other + difference is left for the user and reported. Cleanup list: each scheduled task gets a unique logged name + (`ocx-probe-261010--`) removed with `schtasks /Delete /TN /F`, close no user + windows (each probe exits itself), delete `%USERPROFILE%\ocx-probe-desktop`, the temp record root and + `%TEMP%\ocx-probe-261010`. +5. **Fish real login inside the session.** `printf 'status is-login; and echo LOGIN; status is-interactive; and echo INTERACTIVE; command -v ocx; echo $PATH; exit\n' | script -qec 'su - ocxprobe' /dev/null` + inside the overlay chroot; the same for bash by switching the probe user's shell (`usermod -s /bin/bash`) and + re-running; `fish -l -i -c` stays as a separate control and the clean-PATH `fish -l -c` as the negative control. + +Non-blocking folded: F1's first row is a hypothesis until wp2 evidence (reflection dispositions); F3 depends on the +observed W3/W4 order; F4 depends on L11's failure text. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/021_wp3_execution.md b/devlog/_fin/261010_cli_path_crossos_probe/021_wp3_execution.md new file mode 100644 index 00000000000..1ac5d1429f4 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/021_wp3_execution.md @@ -0,0 +1,88 @@ +# 021 — wp3 execution: two Windows lanes on mini + +Re-verified against `origin/dev` 93ed1a40b4 (no tested file changed since `d17a9f2239`). Architect proposal (Boyle, +01a1225a-9ed0) D1-D6 accepted. This doc supersedes 020 rows W3, W4, W6, W7, W8 and audit item 2's harness wording; +020's other rows and audit items 3-5 stay in force. + +## Decisions + +| ID | Decision | +|---|---| +| D1 | Harness = three `#[ignore]` tests added only to mini's temp clone `%TEMP%\ocx-probe-261010\repo` in a `#[cfg(all(test, windows))]` module of `desktop/src-tauri/src/cli_command.rs` (private fns are reachable there): `probe_bundle_program_files` calls only `windows::stable_bundle(C:\Program Files\OpenCodex\opencodex-desktop.exe, false, "2.77.0")`; `probe_plan_only` calls `windows::plan(&Record::fresh(bundle), bundle, false)` without `transact` (no write; prints would-be HKCU text and issues); `probe_install` runs `record::check(&home, true)` then `perform_selected(\.opencodex-desktop, &home, Action::Reconcile, Some(fresh record with install_id), \|\| windows::stable_bundle(%USERPROFILE%\ocx-probe-desktop\opencodex-desktop.exe, false, "2.82.0-probe"), \|_\| {})`; `probe_remove` runs `perform_selected(..., Action::Remove, None, ...)`. `` = `%TEMP%\ocx-probe-261010\home` (exists before the call). Each prints its Status as JSON plus the raw HKCU Path text/kind before and after, and its own process SessionId. The staging dir holds copies of the installed `opencodex-desktop.exe` and `ocx.exe`; neither copy is ever executed. Not exercised: Tauri scheduling and home lookup, `identity::install_id` minting, `current_exe`/`package_info`, UI state publication and the CLI page's Repair/Off/Remove, and a packaged launch (single instance). | +| D2 | `probe_install` and `probe_remove` run as the compiled test binary from `/IT` scheduled tasks in session 1 (where Explorer and the real Desktop live), never from SSH; one SSH-launched run of `probe_plan_only` is the no-write control. | +| D3 | Lanes: **W-a** (the only writer: W2 baseline, D1 tests, W4 probes before and after, remove, compare/restore) and **W-b** (read-only against the live runtime plus focused tests and a temp package copy). They share no paths, ports or registry keys. | +| D4 | Live-session limits: the HKCU window (install → remove) stays under 10 minutes; exactly two broadcasts (install, remove); restore runs even after a failure; every task has a unique logged name and is deleted by exact name; each probe window exits itself; Desktop 2.77.0 predates #6816 and cannot read the temp record. | +| D5 | F3 evidence without a write: `probe_plan_only` output (would-be text + `machine-path-conflict`) plus the observed `Get-Command ocx -All` order; W5 is labelled simulated (Machine then User PATH composition). | +| D6 | W6 is module-level removal; UI Remove stays unverified (as Linux L12). The doctor reclaim flag to avoid is `--reclaim-response-temps`. PID 22680 is re-checked at start. | + +## Lane W-a (writer) + +1. `whoami`, `query session`, `Get-Process opencodex-desktop,ocx | select Id,SessionId,Path`; save raw HKCU Path + (`(Get-Item HKCU:\Environment).GetValue('Path',$null,'DoNotExpandEnvironmentNames')`) and `GetValueKind('Path')` to + `%USERPROFILE%\ocx-probe-desktop\path-before.json` plus `reg export HKCU\Environment ...\env-before.reg /y`. +2. Add the four tests to the clone (patch file kept in the lane evidence dir), build with + `cargo test --manifest-path desktop\src-tauri\Cargo.toml --no-run`, locate the test exe; run + `probe_bundle_program_files` and `probe_plan_only` over SSH (`-- --ignored --exact --nocapture`). +3. Write `probe.cmd` (PATH evaluated at run time, `where ocx`, PowerShell dump of self/parent/grandparent name, PID, + SessionId, output to a per-run file, then exit). Baseline the four probes: /IT task `cmd /c probe.cmd`, /IT task + running `explorer.exe probe.cmd`, /IT task `wt.exe -w new new-tab --reloadEnvironment cmd /c probe.cmd`, SSH `cmd`. +4. /IT task runs the test exe `probe_install` (output to file); wait for completion; verify HKCU now starts with the + staging dir and kind unchanged; rerun the four probes. +5. /IT task runs `probe_remove`; compare raw value and kind with step 1; if only the owned entry differs, set the saved + raw value with the saved kind and broadcast; any other difference stops and is reported. +6. Delete tasks by exact name, `%USERPROFILE%\ocx-probe-desktop`, the temp home; keep the clone for W-b until both lanes end. + +Stop: Path differs from baseline before install; harness `Err`, `lock-busy`, `journal-conflict`; a probe window open +after 60 s; any restore difference beyond the owned entry. + +## Lane W-b (read-only) + +1. `Get-Process opencodex-desktop,ocx`, listening port of the sidecar; `bun install --frozen-lockfile` in a second + clone `%TEMP%\ocx-probe-261010\b` (avoid W-a's cargo tree). +2. With the real USERPROFILE: `bun src\cli\index.ts resolve --json`, `status`, `status --json` (`startupSource`, + `cliCommand`), `doctor` (no flags); `node -e` import of `src/service/desktop-supervision.mjs` → + `inspectDesktopSupervision()` result on win32 while the Desktop child is live. +3. Synthetic record diagnostics through a script calling `collectCliPathDiagnostics({home, env, recordRead})` with a + ready win32 record whose CLI is `C:\Program Files\OpenCodex\ocx.exe` (labelled synthetic). +4. Focused tests with the pinned Bun: `tests/lib/bun-runtime-preflight.test.ts`, `tests/service/service-runtime-preflight.test.ts`, + `tests/codex-integration/codex-shim-runtime-preflight.test.ts`, `tests/service/service-desktop-runtime-preflight.test.ts`, + `tests/cli/ocx-launcher-desktop-handoff.test.ts`, `tests/cli/cli-path-diagnostics.test.ts`, `tests/update/update-desktop-owner.test.ts`, + `tests/service/service-ownership-handover.test.ts`, `tests/cli/cli-stop-json.test.ts`, `tests/cli/ocx-launcher-runtime.test.ts` + with HOME/USERPROFILE/OPENCODEX_HOME/CODEX_HOME in temp. +5. W10: temp package copy, bundled Bun and installer removed, Bun 1.4.2 (`bun-windows-x64.zip`) first on PATH → + `using PATH Bun` notice; then with only the user's Bun; capture the failure text and whether it names the Desktop CLI. + +Never run against the live install: `ocx start`, `stop`, `service install/repair/start/restart/stop`, `update`, +`doctor --reclaim-response-temps`. Stop if the Desktop PID or its port changes. + +## Reflection (Boyle): ALIGNED with fixes → dispositions + +1. D1 has four ignored tests (`probe_bundle_program_files`, `probe_plan_only`, `probe_install`, `probe_remove`). +2. Before step 6, W-a copies `path-before.json`, `env-before.reg`, every probe output and the harness outputs to the lane + evidence directory (scp to the coordinator's `.tmp/evidence/mini-a/`). +3. The SSH-versus-session-1 broadcast difference is not observed (two-broadcast limit); it is recorded as unverified and + the harness prints its own SessionId. +4. Task completion: the harness and `probe.cmd` write a `done` marker file last; W-a polls for it and for + `schtasks /Query /TN /V /FO LIST` not Running, with a 120 s timeout that is a stop condition. +5. Step 4 expectations: install Status `phase=partial` with `machine-path-conflict`; `cli.json` `notifyPending=false` + after the broadcast; after `probe_remove`, `enabled=false` and `windows=null`. +6. W-b inspector import: `node --input-type=module -e "import('./src/service/desktop-supervision.mjs').then(async m=>console.log(JSON.stringify(await m.inspectDesktopSupervision())))"`. + +## Audit (independent Sol, 01a12288-5370): FAIL → dispositions + +1. **W-b doctor isolation.** `doctor` runs with `CODEX_HOME` and every other client home set to a temp directory (it + takes a native-main claim that opens SQLite under `CODEX_HOME`, `src/codex/native-main-claim.ts:86`); the real + `USERPROFILE` and `~/.opencodex` stay so it still sees the live runtime. `status`/`resolve` keep the real homes and + their port probes are recorded as observations of the live runtime. +2. **External finally.** W-a wraps steps 4-5 in a PowerShell `try { ... } finally { ... }` driver (kept in the + evidence dir) whose `finally` always: (a) waits for/kills only its own harness task by exact name; (b) runs + `probe_remove` from an /IT task if the record exists (`perform_selected` with `Action::Remove` saves off first and + `recover` rolls back a pending install journal, `cli_command.rs:179-206`); (c) compares raw Path text and kind + with `path-before.json`; (d) if only the owned staging entry differs, writes the saved raw value with the saved + kind and broadcasts once; (e) if anything else differs, leaves the value, preserves the temp record and journals, + and reports. The broadcast limit becomes "install, remove, plus at most one notification retry and one fallback + restoration broadcast". +3. **Exact test selection.** Every invocation is ` cli_command::probe_tests:: --ignored --exact + --nocapture --test-threads=1` (module `probe_tests`), and the driver requires the output line + `test result: ok. 1 passed`, the done marker, and the expected transition (HKCU starts with the staging entry after + install; raw value and kind equal the baseline after remove) before continuing. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/029_wp3_outcome.md b/devlog/_fin/261010_cli_path_crossos_probe/029_wp3_outcome.md new file mode 100644 index 00000000000..ef2cde88f43 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/029_wp3_outcome.md @@ -0,0 +1,26 @@ +# 029 — wp3 outcome: Windows on mini + +Source `d17a9f2239`. Two Sol lanes under 021: W-a (the only writer, session-1 harness) and W-b (read-only against +the live Desktop 2.77.0 runtime). Raw evidence stays in the worktree scratch `.tmp/evidence/mini-{a,b}/`. + +| Row | Result | Evidence | +|---|---|---| +| W1 Rust `cli_command` tests | **coordinator-reported, raw output unavailable** — `27 passed; 0 failed` on `x86_64-pc-windows-msvc` | recorded in 001; the raw output was not retained | +| Install eligibility | **works** — `stable_bundle(C:\Program Files\OpenCodex\opencodex-desktop.exe)` → `kind: windows-install`, CLI `C:\Program Files\OpenCodex\ocx.exe`; the per-machine MSI qualifies | mini-a `harness-bundle.txt` | +| W3 real HKCU write (module level, `perform_selected` from an /IT task in session 1) | **works** — HKCU `Path` gained the staging directory first, `REG_EXPAND_SZ` kept, persisted `notifyPending:false` after the broadcast; Status `phase: partial` with `machine-path-conflict` (see F7) | mini-a `harness-install.txt`, `state-after.json` | +| W4 new terminal pickup (release QA) | **works** — after the write, a Windows Terminal tab (`wt -w new new-tab --reloadEnvironment`, parent `WindowsTerminal.exe`, session 1), an Explorer-launched `cmd` (parent `explorer.exe`, session 1) and a Task Scheduler `cmd` all carried the staging directory and `where ocx` returned `%USERPROFILE%\ocx-probe-desktop\ocx.exe` **first**, ahead of nvm4w; before the write all three found nvm4w first. The SSH shell (session 0) stayed stale, as expected | mini-a `probe-pre-*.txt`, `probe-post-*.txt` | +| W5 machine conflict | **broken (diagnostic, F7)** — `machine_conflict()` (`cli_command_windows.rs:805`) expands Machine `Path` with the Desktop's own environment and reports `machine-path-conflict` for `%NVM_SYMLINK%` (`C:\nvm4w\nodejs\ocx.cmd`). Fresh logon environments on mini keep `%NVM_HOME%;%NVM_SYMLINK%` literal (Machine `Path` is `REG_EXPAND_SZ` and both variables exist at machine scope), so nvm4w's `ocx` only arrives through the User `Path`, after the Desktop entry. The Desktop would show "partial" while its `ocx` actually wins | mini-a `machine-path.json`, `harness-plan.txt`, `probe-post-wt.txt` | +| W6 removal (module level) | **works** — `Action::Remove` → `enabled:false`, `phase: disabled`, `windows: null`; raw `Path` text and kind equal the baseline byte for byte; HKCU window 0.18 min | mini-a `harness-remove.txt`, `cli-after-remove.json`, `state-final.json` | +| Packaged Desktop launch, Tauri home lookup, install-id minting, CLI page actions | **unverified** — single instance with the user's live 2.77.0 (`lib.rs:298`); SSH-versus-session-1 broadcast difference also unobserved | — | +| W7 handoff | **by design** — package launcher never hands off on Windows (`windows-path-only`); `status` prints `package handoff=disabled on Windows (user Path selects Desktop)`; synthetic ready record → `packageHandoff: disabled-on-windows`, `path-first-not-desktop` | mini-b `live.out`, `synthetic.out` | +| W7 status/doctor/resolve with the live Desktop | **broken (F2)** — `resolve --json` `supervisor: {"kind":"unsupported"}` and `inspectDesktopSupervision()` → `unsupported` while `Win32_Process` shows sidecar 33476 is the child of `opencodex-desktop` 22680, both under `C:\Program Files\OpenCodex`, session 1. `status` tells the user to restart the proxy with `ocx service restart` (version skew 2.82.0 vs 2.77.0), reports `Restart safety: AT RISK` and `run 'ocx service repair'`; `doctor` (isolated `CODEX_HOME`) recommends `ocx service repair` and `ocx service restart` — the advice #6802 suppresses on macOS/Linux | mini-b `live.out`, `doctor.out` | +| W8 refuse-to-compete (#6809) | **not active on Windows (F2)** — by code: `ocx stop` takes the ordinary stop path (no Desktop warning, `desktop-runtime-guidance.ts:27`), `service install` passes `desktop-command-guard.ts:24` and may clear existing managers and the tracked listener, `update` gets no Desktop veto from `runtime-ownership.mjs:45`. Not run live | mini-b report | +| W9 focused tests | **broken (tests, F6)** — 246 pass / 23 skip / 3 fail. `codex-shim-runtime-preflight.test.ts:86`, `:111` and `service-runtime-preflight.test.ts:120` expect the absolute `process.execPath` in generated `.cmd` files, but both generators intentionally render paths under the profile as `%LOCALAPPDATA%\...`/`%USERPROFILE%\...` (`src/lib/win-paths.ts`); any Windows machine whose Bun lives in the default `~\.bun\bin` fails, while CI's tool-cache Bun passes | mini-b `tests.out` | +| W10 PATH-Bun fallback | **works** — Bun 1.4.2 first on PATH: `opencodex: using PATH Bun 1.4.2.`, exit 0; user's 1.3.14 only: `the \`bun\` dependency is not installed.`, exit 1, no Desktop CLI named (F4 also holds on Windows) | mini-b `w10_run.out` | + +Host state after wp3: raw HKCU `Path` and kind equal the baseline; no `ocx-probe-261010-*` tasks; staging directory, +temp homes, lane clone `b` and W-a's source patch removed; Desktop PID 22680 and sidecar 33476 unchanged in session 1; +ports 10100/10200/52599 unchanged. The coordinator removes `%TEMP%\ocx-probe-261010` at closeout. + +Secret handling: W-a's full `reg export HKCU\Environment` contained an authentication-token value; the raw copy was +deleted on mini and locally, and only a PATH-only export is kept. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/030_release_qa_matrix.md b/devlog/_fin/261010_cli_path_crossos_probe/030_release_qa_matrix.md new file mode 100644 index 00000000000..1d318d642fe --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/030_release_qa_matrix.md @@ -0,0 +1,23 @@ +# 030 — Release-QA items and the shared findings matrix + +The three release-QA items left open by `261009_desktop_owned_path_cli/049_outcome.md`, plus the matrix both probes +fill. Rows are filled in B of wp2/wp3 with the command and output tail. + +| Item | Host | Probe | Result | +|---|---|---|---| +| Fish in a real login shell | lidge | 010 L4/L6/L7 (portable fish 4.9.3 as the probe user's login shell) | works (019 L7); non-interactive fish keeps `/usr/bin/ocx` by design | +| Linux .deb path | lidge | 010 L3/L5/L6/L12 | works for build, install kind, reconcile and rc blocks (019); opt-out unverified | +| New Windows terminal after a real registry write | mini | 020 W3/W4/W6 | works (029 W4): Terminal tab with `--reloadEnvironment`, Explorer-launched and Task Scheduler `cmd` select the Desktop `ocx` first; module-level write, packaged launch unverified | +| Packaged click-through of the CLI page | — | not in this unit's reach without a GUI operator; recorded as unverified unless the Linux Xvfb run exposes the page | pending | + +## Matrix + +| Surface | Linux (lidge) | Windows (mini) | +|---|---|---| +| PATH installer (#6816) | L1, L2, L6 (packaged deb in overlay) | W1, W3 (module level; packaged launch unverified) | +| New shell / terminal pickup | L7 | W4 | +| Package launcher handoff (#6818) | L8 | W7 (by design: none) | +| status / doctor / resolve (#6802, #6818) | L9 | W7 | +| refuse-to-compete guards (#6809) | L10 | W8 | +| Bun fallback (#6807) / preflight (#6812) | L11 | W9, W10 | +| Opt-out cleanup | L12 | W6 | diff --git a/devlog/_fin/261010_cli_path_crossos_probe/040_fixes_issues_closeout.md b/devlog/_fin/261010_cli_path_crossos_probe/040_fixes_issues_closeout.md new file mode 100644 index 00000000000..14fd4259949 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/040_fixes_issues_closeout.md @@ -0,0 +1,43 @@ +# 040 — wp4: fixes, issues and closeout + +Each defect found in wp2/wp3 becomes a PR to `dev` (focused tests on the host where it reproduces, exact-head CI, +independent Sol review) or an issue when it needs a design decision. PR branches are cut from `origin/dev` in +`.tmp/lanes/` inside this worktree. PRs follow `.github/PULL_REQUEST_TEMPLATE.md`; issues use the matching +`.github/ISSUE_TEMPLATE/*.yml` headings. + +## Known before the probes + +| ID | Finding | Evidence | Disposition | +|---|---|---|---| +| F1 | `cli_command_record::tests::final_save_before_journal_deletion_is_cleaned_on_reopen` failed once on Linux with `lock-busy` (1 of 4 full `cli_command` runs on lidge; isolated 5/5 pass). `lock()` (`cli_command_record.rs:536`) takes `flock(LOCK_EX\|LOCK_NB)` once. Hypothesis at the time (never confirmed): a child forked by a parallel test briefly shares the lock's open file description until exec, so a drop-then-reopen sees the lock still held. | lidge cargo log | Superseded after wp2: not reproduced in 60 runs and no lock holder captured, so no PR and no issue (see "After wp2") | +| F2 | Windows has no Desktop-supervision detection: #6802 authority, #6809 refusals and #6818 handoff never engage, so a package `ocx stop`/`service install`/`update` can act against a Desktop-owned runtime | 020 W7/W8 | Issue (feature proposal): Windows process-identity proof for the Desktop child (parent PID + image path via `QueryFullProcessImageNameW`/toolhelp snapshot), equivalent to the Linux `/proc` checks | +| F3 | Machine Path npm (nvm4w, Program Files nodejs) always beats the HKCU entry; Desktop reports `partial` but the npm `ocx` stays selected and, without a handoff, can be far older than the Desktop | 020 W3/W4 | Issue or docs PR depending on W4 evidence | + +Further rows are appended from the probe results. Closeout records PR/issue links, head SHAs, CI run ids, review +verdicts and the host revert table. + +## Reflection dispositions + +- F1 stays a hypothesis until wp2 captures the lock holder (010 dispositions); the PR follows that evidence. +- F2 is about supervision only; the Windows handoff being PATH-only is a separate, deliberate design choice and is + described as such. +- F4 (candidate): on Linux, a package launcher that cannot run names no Desktop CLI because `findDesktopCli` is + macOS-only (`src/lib/bun-path-runtime.mjs:79`), even though the deb ships `/usr/bin/ocx`. + +## After wp2 + +- F1: not reproduced in 60 runs; no PR. Mentioned in the closeout as an unexplained single failure. +- F4: confirmed (`Bun binary missing after install attempt.`); follow-up rather than a fix, see 019. +- F5 (new, fixable): `real_shell_selects_desktop_shim_on_temp_home` requires exactly two stdout lines from + `bash -l -i`, which fails on stock Ubuntu for any sudo-group user because `/etc/bash.bashrc` prints the sudo hint. + PR: emit sentinel-prefixed lines (`printf 'OCX-PATH:%s\nOCX-OUT:%s\n' ...`) and assert on those lines only. + +## After wp3 + +| ID | Disposition | +|---|---| +| F2 | Confirmed live (029 W7/W8). Issue (feature proposal): Windows Desktop-supervision detection from `Win32_Process`-equivalent facts (parent PID, both image paths under the install directory, session, creation time, two agreeing snapshots), so #6802 authority and #6809 refusals apply; PATH-only handoff stays a separate decision. | +| F3 | Superseded by F7: on mini the Desktop `ocx` does win in new terminals. | +| F4 | Holds on Linux and Windows; folded into the F2 issue as a related follow-up (name the Desktop CLI in launcher failure text off macOS). | +| F6 | PR: compute the expected rendered path with `windowsEnvIndirectBatchValue` in the three assertions. | +| F7 | Issue (bug): `machine-path-conflict` false positive; evaluate Machine `Path` as a fresh logon environment does (e.g. `CreateEnvironmentBlock` with `bInherit = FALSE`) instead of `ExpandEnvironmentStringsW` in the Desktop process. | diff --git a/devlog/_fin/261010_cli_path_crossos_probe/041_wp4_execution.md b/devlog/_fin/261010_cli_path_crossos_probe/041_wp4_execution.md new file mode 100644 index 00000000000..4f621db922d --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/041_wp4_execution.md @@ -0,0 +1,127 @@ +# 041 — wp4 execution: two fix PRs, two issues, closeout + +Stale check: `origin/dev` 93ed1a40b4; none of the files below changed since `d17a9f2239`. Neither test file is in +`tests/fixtures/file-size-baseline.json`. `gh` is unauthenticated and `git push` has no credential on this Mac, so +branches, commits and PRs are created through the GitHub connector (blob → tree → commit on the `dev` head → +`create_branch` at that commit → `create_pull_request` to `dev`), from local commits made in lane worktrees. + +## PR 1 — F5: real-shell evidence test survives system rc output + +Lane worktree `.tmp/lanes/f5-real-shell-markers` (inside this worktree), branch `codex/real-shell-test-markers` from +`origin/dev`. MODIFY `desktop/src-tauri/src/cli_command_posix.rs` in `real_shell_selects_desktop_shim_on_temp_home`: + +```diff +- let output = command +- .args(["-i", "-c", "command -v ocx; ocx hello"]) ++ // System rc files may print (Ubuntu's /etc/bash.bashrc sudo hint); only marked lines count. ++ let output = command ++ .args([ ++ "-i", ++ "-c", ++ "printf 'OCX-PATH:%s\\n' \"$(command -v ocx)\"; printf 'OCX-OUT:%s\\n' \"$(ocx hello)\"", ++ ]) + .output() + .expect("cannot start real shell"); + assert!(output.status.success(), "{shell}: command failed"); + let stdout = String::from_utf8(output.stdout).expect("shell stdout is not UTF-8"); +- let lines: Vec<_> = stdout.lines().collect(); ++ let marked = |tag: &str| stdout.lines().filter_map(|l| l.strip_prefix(tag)).collect::>(); ++ let (paths, outputs) = (marked("OCX-PATH:"), marked("OCX-OUT:")); + // Keep captured system-rc output and temporary HOME paths out of failure logs. + assert!( +- lines.len() == 2, +- "{shell}: expected exactly two stdout lines" ++ paths.len() == 1 && outputs.len() == 1, ++ "{shell}: expected exactly one marked path and one marked output line" + ); + assert!( +- lines[0] == expected_path.to_str().unwrap(), ++ paths[0] == expected_path.to_str().unwrap(), + "{shell}: command resolution selected the wrong executable" + ); +- assert!(lines[1] == expected_output, "{shell}: wrong CLI output"); ++ assert!(outputs[0] == expected_output, "{shell}: wrong CLI output"); +``` + +(final layout per `cargo fmt`). Verification: on lidge (Ubuntu, sudo-group user, the failing case) and on this Mac +(zsh + bash), `cargo test --manifest-path desktop/src-tauri/Cargo.toml cli_command_posix::tests::real_shell_selects_desktop_shim_on_temp_home -- --ignored --exact --nocapture` +must pass (lidge failed before with `expected exactly two stdout lines`); `cargo fmt --check` and +`cargo clippy --all-targets -- -D warnings` for the desktop crate (CI stubs as in ci.yml:1567-1573); plain +`cargo test ... cli_command` stays green. Activation: lidge's `/etc/bash.bashrc` sudo hint is the scenario; the +test only passes there if the extra stdout line is ignored. + +## PR 2 — F6: Windows preflight assertions follow the env-indirect path rendering + +Lane worktree `.tmp/lanes/f6-preflight-test-paths`, branch `codex/preflight-tests-env-indirect-paths`. + +- MODIFY `tests/codex-integration/codex-shim-runtime-preflight.test.ts`: import + `windowsEnvIndirectBatchValue` from `../../src/lib/win-paths`; add + `const renderedRuntimePath = () => windowsEnvIndirectBatchValue(runtime.path, value => value);` with a one-line + comment (wrappers write profile paths as `%LOCALAPPDATA%`/`%USERPROFILE%` tokens, `src/lib/win-paths.ts`); lines 86 + and 111 change `toContain(runtime.path)` → `toContain(renderedRuntimePath())`. +- MODIFY `tests/service/service-runtime-preflight.test.ts`: same import; line 120 + `toContain(runtime.path)` → `toContain(windowsEnvIndirectBatchValue(runtime.path, value => value))`. + +The rendered value equals `runtime.path` when no indirection variable prefixes it (macOS, Linux, CI's tool-cache Bun), +so the assertions keep their meaning there and become correct for a Bun under the profile. The identity escape is +enough because `process.execPath` contains none of `% ^ "`; a comment says so. + +Verification: on mini (Windows, Bun under `%USERPROFILE%\.bun` — the failing case), `bun test +tests/codex-integration/codex-shim-runtime-preflight.test.ts tests/service/service-runtime-preflight.test.ts` with +isolated homes → 0 fail (was 3 fail); same two files on this Mac → 0 fail; `bun run typecheck`. + +## Issues + +| Issue | Template | Content | +|---|---|---| +| I1 Windows Desktop supervision (F2, F4) | `feature_request.yml` headings (Area, What are you trying to accomplish?, What prevents this today?, What should OpenCodex do?, Example usage or interface, Alternatives or workarounds, Additional context, Checks) | 029 W7/W8 evidence: `supervisor: unsupported` with the Win32 parent/child facts, status/doctor service advice, which #6802/#6809 paths stay inert; proposal: Windows process-identity proof (parent PID, both image paths in the install directory, session, creation time, two agreeing snapshots) feeding `inspectDesktopSupervision`; F4 as a related follow-up (name the Desktop CLI in launcher failure text off macOS: Windows `InstallLocation`, Linux deb `/usr/bin/ocx` only when it is the package's ELF) | +| I2 `machine-path-conflict` false positive (F7) | `bug_report.yml` headings (Client or integration, Area, Summary, Reproduction, Version, Operating system, Provider and model, Logs or error output, Screenshots and supporting files, Redacted configuration, Checks) | 029 W3/W4/W5 evidence; proposed direction: read `PATH` from `CreateEnvironmentBlock(token, NULL, FALSE)` and test whether any entry before the Desktop entry holds `ocx.{exe,com,cmd,bat}` | + +Issue bodies are drafted in scratch (`.tmp/issues/`), contain no account data, and are created with the connector. + +## Closeout (wp4 C/D) + +1. Independent Sol review of both PR diffs (exact head) and of 019/029/040; record the verdict and rounds. +2. Exact-head CI for both PRs observed through the connector; Windows shards do not run on ordinary PR CI, so mini's + run is the Windows evidence for PR 2 (stated in the PR). +3. Remove mini's `%TEMP%\ocx-probe-261010` and any lidge leftovers; final host table in 049. +4. 049_outcome.md: findings matrix summary, PR/issue links, heads, CI runs, reverts. The devlog unit itself is + published as a third, docs-only PR from this branch (`codex/cli-path-crossos-probe`). + +## Architect consultation (Sol 01a1229a-dfe4, proposal written before reading this doc) + +| ID | Proposal | Disposition | +|---|---|---| +| P1 | Sentinel-prefixed result lines; capture each command and keep its failure status; shared bash/zsh syntax; reject `.hushlogin` (changes the tested environment) and stderr redirection (the hint is on stdout) | accepted; the `-c` script becomes `p=$(command -v ocx) \|\| exit 11; o=$(ocx hello) \|\| exit 12; printf 'OCX-PATH:%s\\nOCX-OUT:%s:OCX-END\\n' "$p" "$o"`, so a failing lookup or CLI still fails `status.success()`; final #6851 head `d785fb1b02` also requires `outputs[0] == format!("{expected_output}:OCX-END")`, so an extra CLI output line fails the comparison | +| P2 | Expected value from `windowsEnvIndirectBatchValue`; assert the complete `set "OCX_BUN=..."` line; keep the frozen-runtime and `unprobed.exe` checks | accepted; the three assertions check `set "OCX_BUN="` (the shim and the service script both emit that line), using the test-local `batchValue` escape for the literal suffix (see audit item 1) | +| P3a | Feature issue for Windows supervision with the observed trigger, the proof shape, `unknown` on inconclusive evidence; PATH-only handoff stays separate; F4 related, Linux needs deb-vs-npm-symlink distinction | accepted as I1 | +| P3b | Bug issue for F7; desired behaviour is fresh-logon PATH evaluation; validate `CreateEnvironmentBlock(FALSE)` against real new terminals before choosing it; no speculative PR | accepted as I2 (wording: candidate direction, to be validated) | +| P4 | No issue for F1; keep the observation and capture the lock holder on recurrence | accepted | + +Reflection: the proposal and this plan match on every decision; the only amendments are the two concrete P1/P2 forms +above. + +## Audit (independent Sol, 01a1229c-7b16): NEAR-PASS → dispositions + +1. PR2 expected values use a test-local `batchValue` that applies the generators' own suffix escaping + (`%` → `%%`, `^` → `^^`, drop `"`; `src/codex/shim-templates.ts:170`, `src/service/windows-taskxml.ts:14`) through + `windowsEnvIndirectBatchValue(runtime.path, batchValue)`, and assert the complete `set "OCX_BUN=..."` line. The + frozen-runtime and `unprobed.exe` checks stay. +2. Issues use `### ` sections in form order, with the dropdown value or text under each, and the form's + labels (`bug` / `enhancement`). The forms define no `title:` prefix; titles follow the repository convention + (`[Bug] ...` / `[Feature]: ...`). `enforce-issue-quality` detects the kind from content and validates the body + (`.github/scripts/issue-quality-core.cjs` `detectIssueKind`, `validateIssueBody`), not the submission route. Each + PR body fills Summary, Verification and Checklist from `.github/PULL_REQUEST_TEMPLATE.md` with the host evidence. +3. Closeout step 1 records the actual review verdict and its round count rather than presupposing PASS; PR1's Rust + literal keeps `\\n` and plain `||`. + +## Audit round 2 (01a1229c-7b16): NEAR-PASS, one residual → rebuttal + +The forms define no title prefix; the bug form adds the `bug` label and the feature form `enhancement`. Titles follow +the repository's existing convention (`[Bug] ...`, `[Feature]: ...`, e.g. #6720, #3191). + +Residual: AGENTS.md asks agents to open issues through the template chooser. Issue forms can only be submitted through +the web UI and `gh` is unauthenticated here, so the issues are created through the GitHub connector with the body the +form would render (same `###