diff --git a/.ade/cto/identity.yaml b/.ade/cto/identity.yaml
index 6ee811b8..3c0feac2 100644
--- a/.ade/cto/identity.yaml
+++ b/.ade/cto/identity.yaml
@@ -1,13 +1,6 @@
name: CTO
-version: 2
-persona: >-
- You are the CTO for this project inside ADE.
-
- You are the persistent technical lead who owns architecture, execution
- quality, engineering continuity, and team direction.
-
- Use ADE's tools and project context to help the team move forward with clear,
- concrete decisions.
+version: 3
+persona: Persistent project CTO with strategic personality.
personality: strategic
modelPreferences:
provider: claude
@@ -30,5 +23,6 @@ openclawContextPolicy:
- system_prompt
onboardingState:
completedSteps:
- - integrations
-updatedAt: 2026-03-16T04:16:18.954Z
+ - identity
+ completedAt: 2026-03-17T20:35:20.336Z
+updatedAt: 2026-03-17T20:35:20.336Z
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..8372135f
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,17 @@
+version: 2
+updates:
+ - package-ecosystem: "npm"
+ directory: "/apps/desktop"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 10
+ - package-ecosystem: "npm"
+ directory: "/apps/mcp-server"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 5
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 5
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8eebc687..e2119ad9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,39 +10,177 @@ permissions:
contents: read
jobs:
- check:
+ # ── Stage 1: Install & cache ──────────────────────────────────────────
+ install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- cache: npm
- cache-dependency-path: |
- apps/desktop/package-lock.json
- apps/mcp-server/package-lock.json
- apps/web/package-lock.json
- - name: Install MCP server dependencies
- run: cd apps/mcp-server && npm ci
+ - name: Restore node_modules cache
+ id: cache
+ uses: actions/cache@v4
+ with:
+ path: |
+ apps/desktop/node_modules
+ apps/mcp-server/node_modules
+ apps/web/node_modules
+ key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }}
- - name: Install desktop dependencies
- run: cd apps/desktop && npm ci
+ - name: Install all dependencies (parallel)
+ if: steps.cache.outputs.cache-hit != 'true'
+ run: |
+ cd apps/desktop && npm ci &
+ cd apps/mcp-server && npm ci &
+ cd apps/web && npm ci &
+ wait
- - name: Install web dependencies
- run: cd apps/web && npm ci
+ # ── Stage 2: Parallel checks ──────────────────────────────────────────
+ typecheck-desktop:
+ needs: install
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - uses: actions/cache/restore@v4
+ with:
+ path: |
+ apps/desktop/node_modules
+ apps/mcp-server/node_modules
+ apps/web/node_modules
+ key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }}
+ - run: cd apps/desktop && npm run typecheck
- - name: Typecheck
- run: cd apps/desktop && npm run typecheck
+ typecheck-mcp:
+ needs: install
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - uses: actions/cache/restore@v4
+ with:
+ path: |
+ apps/desktop/node_modules
+ apps/mcp-server/node_modules
+ apps/web/node_modules
+ key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }}
+ - run: cd apps/mcp-server && npm run typecheck
+
+ lint-desktop:
+ needs: install
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - uses: actions/cache/restore@v4
+ with:
+ path: |
+ apps/desktop/node_modules
+ apps/mcp-server/node_modules
+ apps/web/node_modules
+ key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }}
+ - run: cd apps/desktop && npm run lint
+
+ test-desktop:
+ needs: install
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - uses: actions/cache/restore@v4
+ with:
+ path: |
+ apps/desktop/node_modules
+ apps/mcp-server/node_modules
+ apps/web/node_modules
+ key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }}
+ - run: cd apps/desktop && npx vitest run --shard=${{ matrix.shard }}/3
- - name: Test desktop
- run: cd apps/desktop && npm test
+ test-mcp:
+ needs: install
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - uses: actions/cache/restore@v4
+ with:
+ path: |
+ apps/desktop/node_modules
+ apps/mcp-server/node_modules
+ apps/web/node_modules
+ key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }}
+ - run: cd apps/mcp-server && npm test
- - name: Test MCP server
- run: cd apps/mcp-server && npm test
+ build:
+ needs: install
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - uses: actions/cache/restore@v4
+ with:
+ path: |
+ apps/desktop/node_modules
+ apps/mcp-server/node_modules
+ apps/web/node_modules
+ key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }}
+ - run: cd apps/desktop && npm run build
+ - run: cd apps/mcp-server && npm run build
+ - run: cd apps/web && npm run build
- - name: Build web
- run: cd apps/web && npm run build
+ validate-docs:
+ needs: install
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - uses: actions/cache/restore@v4
+ with:
+ path: |
+ apps/desktop/node_modules
+ apps/mcp-server/node_modules
+ apps/web/node_modules
+ key: nm-${{ hashFiles('apps/desktop/package-lock.json','apps/mcp-server/package-lock.json','apps/web/package-lock.json') }}
+ - run: node scripts/validate-docs.mjs
- - name: Validate docs
- run: node scripts/validate-docs.mjs
+ # ── Gate: all jobs must pass ──────────────────────────────────────────
+ ci-pass:
+ if: always()
+ needs:
+ - typecheck-desktop
+ - typecheck-mcp
+ - lint-desktop
+ - test-desktop
+ - test-mcp
+ - build
+ - validate-docs
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check all jobs passed
+ run: |
+ if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]] ||
+ [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
+ echo "::error::One or more required jobs failed or were cancelled"
+ exit 1
+ fi
+ echo "All CI jobs passed"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b93017a6..bc0f0f59 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -21,8 +21,58 @@ jobs:
git fetch origin main --depth=1
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
- release:
+ build-mac-app:
needs: verify
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: arm64
+ runner: macos-latest
+ app_dir: mac-arm64
+ - arch: x64
+ runner: macos-15-intel
+ app_dir: mac
+ runs-on: ${{ matrix.runner }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: apps/desktop/package-lock.json
+
+ - name: Install desktop dependencies
+ run: cd apps/desktop && npm ci
+
+ - name: Stamp release version
+ env:
+ ADE_RELEASE_TAG: ${{ github.ref_name }}
+ run: cd apps/desktop && npm run version:release
+
+ - name: Build unsigned macOS app bundle
+ run: cd apps/desktop && npm run dist:mac:dir -- --${{ matrix.arch }}
+
+ - name: Archive app bundle
+ run: |
+ APP_PATH="apps/desktop/release/${{ matrix.app_dir }}/ADE.app"
+ ARCHIVE_PATH="apps/desktop/release/ADE-${{ matrix.arch }}.app.zip"
+ test -d "$APP_PATH"
+ ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ARCHIVE_PATH"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: mac-app-${{ matrix.arch }}
+ path: apps/desktop/release/ADE-${{ matrix.arch }}.app.zip
+ if-no-files-found: error
+
+ release:
+ needs:
+ - verify
+ - build-mac-app
runs-on: macos-latest
concurrency:
group: release-${{ github.ref_name }}
@@ -36,21 +86,51 @@ jobs:
with:
node-version: 22
cache: npm
- cache-dependency-path: |
- apps/desktop/package-lock.json
- apps/mcp-server/package-lock.json
+ cache-dependency-path: apps/desktop/package-lock.json
- name: Install desktop dependencies
run: cd apps/desktop && npm ci
- - name: Install MCP server dependencies
- run: cd apps/mcp-server && npm ci
-
- name: Stamp release version
env:
ADE_RELEASE_TAG: ${{ github.ref_name }}
run: cd apps/desktop && npm run version:release
+ - uses: actions/download-artifact@v4
+ with:
+ name: mac-app-arm64
+ path: apps/desktop/release/_ci/downloads/arm64
+
+ - uses: actions/download-artifact@v4
+ with:
+ name: mac-app-x64
+ path: apps/desktop/release/_ci/downloads/x64
+
+ - name: Expand architecture app bundles
+ run: |
+ rm -rf apps/desktop/release/_ci/arm64 apps/desktop/release/_ci/x64
+ mkdir -p apps/desktop/release/_ci/arm64 apps/desktop/release/_ci/x64
+ ditto -x -k apps/desktop/release/_ci/downloads/arm64/ADE-arm64.app.zip apps/desktop/release/_ci/arm64
+ ditto -x -k apps/desktop/release/_ci/downloads/x64/ADE-x64.app.zip apps/desktop/release/_ci/x64
+
+ - name: Merge universal app bundle
+ run: cd apps/desktop && npm run merge:mac:universal
+
+ - name: Materialize App Store Connect API key
+ env:
+ APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
+ APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
+ run: |
+ if [ -z "$APPLE_API_KEY_P8" ] || [ -z "$APPLE_API_KEY_ID" ]; then
+ echo "::error::Missing APPLE_API_KEY_P8 or APPLE_API_KEY_ID GitHub secret."
+ exit 1
+ fi
+
+ KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8"
+ printf '%s' "$APPLE_API_KEY_P8" > "$KEY_PATH"
+ chmod 600 "$KEY_PATH"
+ echo "APPLE_API_KEY=$KEY_PATH" >> "$GITHUB_ENV"
+
- name: Create draft GitHub release with generated notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -64,4 +144,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ELECTRON_CACHE: ${{ runner.temp }}/electron
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder
- run: cd apps/desktop && npm run release:mac
+ CSC_LINK: ${{ secrets.CSC_LINK }}
+ CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
+ APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
+ APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
+ run: cd apps/desktop && npm run release:mac:universal
diff --git a/.gitignore b/.gitignore
index f85e11e0..4f20e767 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,4 @@ xcuserdata/
.pnpm-store/
/apps/desktop/.ade
/.playwright-mcp
+/.codex-derived-data
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5b84883c..d7b080a4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,6 +17,62 @@ npm run dev
- TypeScript strict mode is enabled
- Tests use Vitest
+## Signed macOS releases
+
+For ADE's current release path, the correct Apple objects are:
+
+- `Developer ID Application` certificate for signing the `.app`
+- App Store Connect `Team Key` for notarization
+
+You do not need these for the current ADE flow:
+
+- `Developer ID Installer` certificate, because ADE ships `dmg` + `zip`, not `pkg`
+- A provisioning profile, unless the app later adds Apple advanced capabilities that require a Developer ID provisioning profile
+
+The tagged macOS release workflow expects these GitHub Actions secrets:
+
+- `CSC_LINK` — Developer ID Application certificate (`.p12`), typically base64-encoded
+- `CSC_KEY_PASSWORD` — password for the Developer ID Application certificate
+- `APPLE_API_KEY_P8` — raw contents of the App Store Connect Team API key (`AuthKey_*.p8`)
+- `APPLE_API_KEY_ID` — App Store Connect key ID
+- `APPLE_API_ISSUER` — App Store Connect issuer ID
+
+The release workflow builds ADE in three stages:
+
+1. `arm64` app bundle on `macos-latest`
+2. `x64` app bundle on `macos-15-intel`
+3. universal app merge, then signing, notarization, `dmg`/`zip` packaging, and GitHub release publish from the merged app
+
+Current Apple setup flow:
+
+1. On a Mac, create a CSR in Keychain Access using `Certificate Assistant > Request a Certificate from a Certificate Authority`, and save it to disk.
+2. In Apple Developer > Certificates, Identifiers & Profiles > Certificates, click `+`.
+3. Under `Software`, choose `Developer ID`, then choose `Developer ID Application`.
+4. Upload the CSR, download the `.cer`, and double-click it so it appears in Keychain Access under `login > My Certificates`.
+5. Export that certificate from Keychain Access as a `.p12` file with a password. This is the certificate material used by `CSC_LINK`.
+6. In App Store Connect > Users and Access > Integrations > Team Keys, generate a Team API key and download the `.p8` file. Note the key ID and issuer ID.
+
+To test a signed macOS build locally, export the matching environment variables expected by `electron-builder` and run:
+
+```bash
+cd apps/desktop
+export CSC_LINK=/absolute/path/to/DeveloperIDApplication.p12
+export CSC_KEY_PASSWORD=...
+export APPLE_API_KEY=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
+export APPLE_API_KEY_ID=XXXXXXXXXX
+export APPLE_API_ISSUER=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
+npm run dist:mac:signed
+```
+
+To test the unsigned intermediate app bundle that the CI workflow produces per architecture, run:
+
+```bash
+cd apps/desktop
+npm run dist:mac:dir -- --arm64
+```
+
+The tagged release workflow should be run from a tag that points at `main`. Push the release tag only after the intended `main` commit is in place.
+
## Code Style
- TypeScript with strict mode
diff --git a/README.md b/README.md
index 3a180799..977508dd 100644
--- a/README.md
+++ b/README.md
@@ -63,28 +63,18 @@ ADE is built for people who want agents to operate inside a real development wor
1. Download the latest `.dmg` from [**Releases**](https://github.com/arul28/ADE/releases)
2. Open the `.dmg` and drag **ADE** into your Applications folder
-3. Move **ADE** into your Applications folder before trying to launch it
-4. Clear macOS quarantine for the installed app bundle:
-
-```bash
-xattr -dr com.apple.quarantine /Applications/ADE.app
-```
-
-5. Launch ADE, open a project, and configure your AI provider in Settings
+3. Launch ADE from Applications
+4. Open a project and configure your AI provider in Settings
## Early beta notes
ADE is still a very early beta. Expect rough edges, incomplete workflows, and occasional breaking changes between releases.
-The macOS app is not code signed or notarized yet, so Gatekeeper may block it on first launch. Install the app by dragging it into `Applications` first, then if macOS still refuses to open it, run:
-
-```bash
-xattr -dr com.apple.quarantine /Applications/ADE.app
-```
+Official macOS releases are intended to ship as Developer ID-signed and notarized builds so Gatekeeper accepts them normally and ADE can apply future in-app updates without the quarantine workaround.
-That removes the quarantine attribute Apple adds to downloaded apps and allows ADE to launch locally. Only do this for builds you downloaded from the ADE releases page and trust.
+Older pre-signing beta builds may still need the manual `xattr -dr com.apple.quarantine /Applications/ADE.app` step if you are testing an older release artifact.
-ADE auto-updates. When a new version is available, an update button appears in the header bar -- click it to restart and apply.
+ADE auto-updates. When a new version is available, an update button appears in the header bar. Click it to restart and apply the signed update.
## Contributing
diff --git a/apps/desktop/build/entitlements.mac.inherit.plist b/apps/desktop/build/entitlements.mac.inherit.plist
new file mode 100644
index 00000000..393574a3
--- /dev/null
+++ b/apps/desktop/build/entitlements.mac.inherit.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.inherit
+
+
+
diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist
new file mode 100644
index 00000000..896ab565
--- /dev/null
+++ b/apps/desktop/build/entitlements.mac.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json
index 95b6388d..c4274a41 100644
--- a/apps/desktop/package-lock.json
+++ b/apps/desktop/package-lock.json
@@ -26,6 +26,7 @@
"ai": "^6.0.94",
"ai-sdk-provider-claude-code": "^3.4.2",
"ai-sdk-provider-codex-cli": "^1.1.0",
+ "bonjour-service": "^1.3.0",
"chokidar": "^4.0.3",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
@@ -38,6 +39,7 @@
"node-cron": "^3.0.3",
"node-pty": "^1.1.0",
"onnxruntime-node": "^1.24.3",
+ "qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
@@ -53,6 +55,7 @@
},
"devDependencies": {
"@electron/rebuild": "^4.0.1",
+ "@electron/universal": "^2.0.3",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
@@ -60,6 +63,7 @@
"@types/dagre": "^0.7.53",
"@types/node": "^20.11.30",
"@types/node-cron": "^3.0.11",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.24",
"@types/sql.js": "^1.4.9",
@@ -2349,6 +2353,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@leichtgewicht/ip-codec": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
+ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
+ "license": "MIT"
+ },
"node_modules/@malept/cross-spawn-promise": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
@@ -4565,6 +4575,16 @@
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+ "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
@@ -5345,7 +5365,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5355,7 +5374,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -5854,6 +5872,16 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/bonjour-service": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz",
+ "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "multicast-dns": "^7.2.5"
+ }
+ },
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -6208,6 +6236,15 @@
"node": ">=6"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001779",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz",
@@ -6495,7 +6532,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -6508,7 +6544,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -6894,6 +6929,15 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -7092,6 +7136,12 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/dir-compare": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
@@ -7248,6 +7298,18 @@
"license": "MIT",
"optional": true
},
+ "node_modules/dns-packet": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
+ "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@leichtgewicht/ip-codec": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -7631,7 +7693,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -8633,7 +8694,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -9321,7 +9381,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -11452,6 +11511,19 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/multicast-dns": {
+ "version": "7.2.5",
+ "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
+ "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
+ "license": "MIT",
+ "dependencies": {
+ "dns-packet": "^5.2.2",
+ "thunky": "^1.0.2"
+ },
+ "bin": {
+ "multicast-dns": "cli.js"
+ }
+ },
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -11941,6 +12013,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -12012,7 +12093,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -12191,6 +12271,15 @@
"node": ">=10.4.0"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -12472,6 +12561,141 @@
"node": ">=6"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/qrcode/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/qrcode/node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
@@ -12878,7 +13102,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -12893,6 +13116,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -13277,6 +13506,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -13645,7 +13880,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -13690,7 +13924,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -14015,6 +14248,12 @@
"node": ">=0.8"
}
},
+ "node_modules/thunky": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
+ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
+ "license": "MIT"
+ },
"node_modules/tiny-async-pool": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
@@ -15439,6 +15678,12 @@
"node": ">= 8"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index d58c921b..240851a2 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -11,9 +11,18 @@
"dev": "node ./scripts/ensure-electron.cjs && node ./scripts/dev.cjs",
"build": "tsup && vite build",
"dist:mac": "npm run build && npm run rebuild:native && electron-builder --mac --publish never",
- "release:mac": "npm run build && npm run rebuild:native && electron-builder --mac --publish always",
+ "dist:mac:dir": "npm run build && npm run rebuild:native && electron-builder --dir --mac --publish never -c.mac.identity=null -c.mac.notarize=false",
+ "dist:mac:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run build && npm run rebuild:native && electron-builder --mac --publish never",
+ "merge:mac:universal": "node ./scripts/make-universal-mac.mjs",
+ "dist:mac:universal:signed": "node ./scripts/require-macos-release-secrets.cjs && electron-builder --mac --universal --publish never --prepackaged release/mac-universal/ADE.app",
+ "release:mac": "node ./scripts/require-macos-release-secrets.cjs && npm run build && npm run rebuild:native && electron-builder --mac --publish always",
+ "release:mac:universal": "node ./scripts/require-macos-release-secrets.cjs && electron-builder --mac --universal --publish always --prepackaged release/mac-universal/ADE.app",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
+ "test:unit": "vitest run --project unit",
+ "test:integration": "vitest run --project integration",
+ "test:component": "vitest run --project component",
+ "test:coverage": "vitest run --coverage",
"test:orchestrator-smoke": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts --reporter=verbose",
"test:orchestrator-complex-mock": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts -t \"complex mock prompt\" --reporter=verbose",
"mcp:dev": "npm --prefix ../mcp-server run dev -- --project-root ../..",
@@ -44,6 +53,7 @@
"ai": "^6.0.94",
"ai-sdk-provider-claude-code": "^3.4.2",
"ai-sdk-provider-codex-cli": "^1.1.0",
+ "bonjour-service": "^1.3.0",
"chokidar": "^4.0.3",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
@@ -56,6 +66,7 @@
"node-cron": "^3.0.3",
"node-pty": "^1.1.0",
"onnxruntime-node": "^1.24.3",
+ "qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
@@ -70,6 +81,8 @@
"zustand": "^5.0.11"
},
"devDependencies": {
+ "@electron/universal": "^2.0.3",
+ "@electron/rebuild": "^4.0.1",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
@@ -77,19 +90,19 @@
"@types/dagre": "^0.7.53",
"@types/node": "^20.11.30",
"@types/node-cron": "^3.0.11",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.24",
"@types/sql.js": "^1.4.9",
"@types/ws": "^8.18.1",
+ "@typescript-eslint/eslint-plugin": "^6.21.0",
+ "@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.24",
"concurrently": "^9.1.0",
"cross-env": "^7.0.3",
"electron": "^40.2.1",
- "@electron/rebuild": "^4.0.1",
"electron-builder": "^26.8.1",
- "@typescript-eslint/eslint-plugin": "^6.21.0",
- "@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.57.1",
"eslint-plugin-react-hooks": "^4.6.2",
"jsdom": "^22.1.0",
@@ -129,7 +142,12 @@
"zip"
],
"icon": "build/icon.icns",
- "category": "public.app-category.developer-tools"
+ "category": "public.app-category.developer-tools",
+ "hardenedRuntime": true,
+ "gatekeeperAssess": false,
+ "entitlements": "build/entitlements.mac.plist",
+ "entitlementsInherit": "build/entitlements.mac.inherit.plist",
+ "notarize": true
}
}
}
diff --git a/apps/desktop/scripts/make-universal-mac.mjs b/apps/desktop/scripts/make-universal-mac.mjs
new file mode 100644
index 00000000..dd9d3316
--- /dev/null
+++ b/apps/desktop/scripts/make-universal-mac.mjs
@@ -0,0 +1,64 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { makeUniversalApp } from "@electron/universal";
+
+const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+const appDir = path.resolve(scriptDir, "..");
+
+function readFlag(name) {
+ const prefix = `${name}=`;
+ for (const arg of process.argv.slice(2)) {
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length);
+ }
+ return null;
+}
+
+function hasFlag(name) {
+ return process.argv.slice(2).includes(name);
+}
+
+function resolveAbsolute(input, fallback) {
+ const value = input?.trim() || fallback;
+ if (!value) {
+ throw new Error("Missing required path argument");
+ }
+ return path.isAbsolute(value) ? value : path.resolve(appDir, value);
+}
+
+const x64AppPath = resolveAbsolute(
+ readFlag("--x64-app") ?? process.env.ADE_X64_APP_PATH,
+ path.join(appDir, "release", "_ci", "x64", "ADE.app"),
+);
+const arm64AppPath = resolveAbsolute(
+ readFlag("--arm64-app") ?? process.env.ADE_ARM64_APP_PATH,
+ path.join(appDir, "release", "_ci", "arm64", "ADE.app"),
+);
+const outAppPath = resolveAbsolute(
+ readFlag("--out-app") ?? process.env.ADE_UNIVERSAL_APP_PATH,
+ path.join(appDir, "release", "mac-universal", "ADE.app"),
+);
+
+const mergeAsars = hasFlag("--merge-asars") || process.env.ADE_MERGE_ASARS === "1";
+const singleArchFiles = readFlag("--single-arch-files") ?? process.env.ADE_SINGLE_ARCH_FILES ?? undefined;
+const x64ArchFiles = readFlag("--x64-arch-files") ?? process.env.ADE_X64_ARCH_FILES ?? undefined;
+
+await fs.access(x64AppPath);
+await fs.access(arm64AppPath);
+await fs.rm(outAppPath, { recursive: true, force: true });
+await fs.mkdir(path.dirname(outAppPath), { recursive: true });
+
+await makeUniversalApp({
+ x64AppPath,
+ arm64AppPath,
+ outAppPath,
+ force: true,
+ mergeASARs: mergeAsars,
+ singleArchFiles,
+ x64ArchFiles,
+});
+
+console.log(
+ `[release:mac] Created universal app bundle at ${outAppPath} ` +
+ `(mergeASARs=${mergeAsars ? "true" : "false"}).`,
+);
diff --git a/apps/desktop/scripts/require-macos-release-secrets.cjs b/apps/desktop/scripts/require-macos-release-secrets.cjs
new file mode 100644
index 00000000..f5aaceb6
--- /dev/null
+++ b/apps/desktop/scripts/require-macos-release-secrets.cjs
@@ -0,0 +1,71 @@
+#!/usr/bin/env node
+"use strict";
+
+function hasEnv(name) {
+ return Boolean(process.env[name] && String(process.env[name]).trim().length > 0);
+}
+
+function formatList(values) {
+ return values.map((value) => `- ${value}`).join("\n");
+}
+
+const missing = [];
+
+for (const name of ["CSC_LINK", "CSC_KEY_PASSWORD"]) {
+ if (!hasEnv(name)) {
+ missing.push(name);
+ }
+}
+
+const notarizationProfiles = [
+ {
+ label: "App Store Connect API key",
+ vars: ["APPLE_API_KEY", "APPLE_API_KEY_ID", "APPLE_API_ISSUER"],
+ },
+ {
+ label: "Apple ID app-specific password",
+ vars: ["APPLE_ID", "APPLE_APP_SPECIFIC_PASSWORD", "APPLE_TEAM_ID"],
+ },
+ {
+ label: "notarytool keychain profile",
+ vars: ["APPLE_KEYCHAIN_PROFILE"],
+ },
+];
+
+const matchingProfile = notarizationProfiles.find((profile) => profile.vars.every(hasEnv));
+
+if (!matchingProfile) {
+ const providedAppleVars = notarizationProfiles.flatMap((profile) => profile.vars).filter(hasEnv);
+ process.stderr.write(
+ "[release:mac] Missing notarization credentials.\n" +
+ "Provide one complete credential set before building a signed macOS release:\n" +
+ formatList(
+ notarizationProfiles.map((profile) => `${profile.label}: ${profile.vars.join(", ")}`)
+ ) +
+ "\n"
+ );
+ if (providedAppleVars.length > 0) {
+ process.stderr.write(
+ `[release:mac] Partial Apple credential environment detected: ${providedAppleVars.join(", ")}\n`
+ );
+ }
+ process.exit(1);
+}
+
+if (missing.length > 0) {
+ process.stderr.write(
+ "[release:mac] Missing required release environment variables:\n" + formatList(missing) + "\n"
+ );
+ process.exit(1);
+}
+
+if (matchingProfile.vars.includes("APPLE_API_KEY") && !String(process.env.APPLE_API_KEY).startsWith("/")) {
+ process.stderr.write(
+ "[release:mac] APPLE_API_KEY must point to the absolute path of the App Store Connect .p8 key file.\n"
+ );
+ process.exit(1);
+}
+
+process.stdout.write(
+ `[release:mac] macOS signing and notarization environment looks complete (${matchingProfile.label}).\n`
+);
diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts
index 33558962..1ca7b6af 100644
--- a/apps/desktop/src/main/main.ts
+++ b/apps/desktop/src/main/main.ts
@@ -26,7 +26,6 @@ import { createTestService } from "./services/tests/testService";
import { createOperationService } from "./services/history/operationService";
import { createGitOperationsService } from "./services/git/gitOperationsService";
import { runGit } from "./services/git/git";
-import { createPackService } from "./services/packs/packService";
import { createJobEngine } from "./services/jobs/jobEngine";
import { createAiIntegrationService } from "./services/ai/aiIntegrationService";
import { createAgentChatService } from "./services/chat/agentChatService";
@@ -714,20 +713,6 @@ app.whenReady().then(async () => {
projectRoot,
});
- const packService = createPackService({
- db,
- logger,
- projectRoot,
- projectId,
- packsDir: adePaths.packsDir,
- laneService,
- sessionService,
- projectConfigService,
- operationService,
- aiIntegrationService,
- onEvent: () => {}
- });
-
const onboardingService = createOnboardingService({
db,
logger,
@@ -881,6 +866,16 @@ app.whenReady().then(async () => {
}
});
+ const processService = createProcessService({
+ db,
+ projectId,
+ processLogsDir: adePaths.processLogsDir,
+ logger,
+ laneService,
+ projectConfigService,
+ broadcastEvent: (ev) => emitProjectEvent(projectRoot, IPC.processesEvent, ev)
+ });
+
const onTrackedSessionEnded = ({ laneId, sessionId, exitCode }: { laneId: string; sessionId: string; exitCode: number | null }) => {
jobEngine?.onSessionEnded({ laneId, sessionId });
automationService?.onSessionEnded({ laneId, sessionId });
@@ -1172,7 +1167,7 @@ app.whenReady().then(async () => {
transcriptsDir: adePaths.transcriptsDir,
projectId,
memoryService,
- packService,
+ fileService,
workerAgentService,
workerHeartbeatService,
linearIssueTracker,
@@ -1183,6 +1178,7 @@ app.whenReady().then(async () => {
linearClient,
linearCredentials: linearCredentialService,
prService,
+ processService,
episodicSummaryService,
laneService,
sessionService,
@@ -1249,16 +1245,6 @@ app.whenReady().then(async () => {
onHeadChanged: handleHeadChanged
});
- const processService = createProcessService({
- db,
- projectId,
- processLogsDir: adePaths.processLogsDir,
- logger,
- laneService,
- projectConfigService,
- broadcastEvent: (ev) => emitProjectEvent(projectRoot, IPC.processesEvent, ev)
- });
-
const testService = createTestService({
db,
projectId,
@@ -1501,7 +1487,6 @@ app.whenReady().then(async () => {
db,
projectId,
projectRoot,
- packService,
conflictService,
ptyService,
agentChatService,
@@ -1572,9 +1557,18 @@ app.whenReady().then(async () => {
projectRoot,
fileService,
laneService,
+ gitService,
+ diffService,
+ conflictService,
prService,
sessionService,
ptyService,
+ projectConfigService,
+ portAllocationService,
+ laneEnvironmentService,
+ laneTemplateService,
+ rebaseSuggestionService,
+ autoRebaseService,
computerUseArtifactBrokerService,
missionService,
agentChatService,
@@ -2019,17 +2013,25 @@ app.whenReady().then(async () => {
sessionService,
operationService,
projectConfigService,
- packService,
conflictService,
gitService,
diffService,
missionService,
ptyService,
testService,
+ agentChatService,
prService,
+ fileService,
memoryService,
ctoStateService,
workerAgentService,
+ flowPolicyService,
+ linearDispatcherService,
+ linearIssueTracker,
+ linearSyncService,
+ linearIngressService,
+ linearRoutingService,
+ processService,
externalMcpService,
computerUseArtifactBrokerService,
orchestratorService,
@@ -2130,7 +2132,6 @@ app.whenReady().then(async () => {
missionBudgetService,
aiOrchestratorService,
agentChatService,
- packService,
contextDocService,
projectConfigService,
processService,
@@ -2223,7 +2224,6 @@ app.whenReady().then(async () => {
orchestratorService: null,
missionBudgetService: null,
aiOrchestratorService: null,
- packService: null,
contextDocService: null,
projectConfigService: null,
processService: null,
@@ -2480,17 +2480,16 @@ app.whenReady().then(async () => {
dormantContext = createDormantProjectContext();
+ const FILE_LIMIT_CODES = new Set(["EMFILE", "ENFILE"]);
+ let emfileWarned = false;
process.on("uncaughtException", (err) => {
- // Suppress repeated EMFILE errors to avoid log flooding
- if ((err as NodeJS.ErrnoException).code === "EMFILE" || (err as NodeJS.ErrnoException).code === "ENFILE") return;
+ if (FILE_LIMIT_CODES.has((err as NodeJS.ErrnoException).code ?? "")) return;
getActiveContext().logger.error("process.uncaught_exception", {
err: String(err),
stack: err instanceof Error ? err.stack : undefined
});
});
- let emfileWarned = false;
process.on("unhandledRejection", (reason) => {
- // Suppress repeated EMFILE errors to avoid log flooding
const msg = String(reason);
if (msg.includes("EMFILE") || msg.includes("ENFILE")) {
if (!emfileWarned) {
diff --git a/apps/desktop/src/main/services/ai/agentExecutor.ts b/apps/desktop/src/main/services/ai/agentExecutor.ts
index b82c652a..d6839102 100644
--- a/apps/desktop/src/main/services/ai/agentExecutor.ts
+++ b/apps/desktop/src/main/services/ai/agentExecutor.ts
@@ -26,7 +26,6 @@ export type CodexProviderOverrides = {
export type ExecutorOpts = {
cwd: string;
- contextPack?: unknown;
systemPrompt?: string;
jsonSchema?: unknown;
model?: string;
diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts
index ff6fa746..78406d1e 100644
--- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts
+++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts
@@ -19,6 +19,7 @@ vi.mock("./providerRuntimeHealth", () => ({
let probeClaudeRuntimeHealth: typeof import("./claudeRuntimeProbe").probeClaudeRuntimeHealth;
let resetClaudeRuntimeProbeCache: typeof import("./claudeRuntimeProbe").resetClaudeRuntimeProbeCache;
+let isClaudeRuntimeAuthError: typeof import("./claudeRuntimeProbe").isClaudeRuntimeAuthError;
function makeStream(messages: unknown[]) {
const close = vi.fn();
@@ -42,6 +43,7 @@ beforeEach(async () => {
const mod = await import("./claudeRuntimeProbe");
probeClaudeRuntimeHealth = mod.probeClaudeRuntimeHealth;
resetClaudeRuntimeProbeCache = mod.resetClaudeRuntimeProbeCache;
+ isClaudeRuntimeAuthError = mod.isClaudeRuntimeAuthError;
resetClaudeRuntimeProbeCache();
});
@@ -65,4 +67,44 @@ describe("claudeRuntimeProbe", () => {
expect(mockState.reportProviderRuntimeAuthFailure).toHaveBeenCalledTimes(1);
expect(mockState.reportProviderRuntimeFailure).not.toHaveBeenCalled();
});
+
+ it("treats Anthropic 401 invalid credentials responses as auth failures", async () => {
+ expect(
+ isClaudeRuntimeAuthError(
+ 'API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
+ ),
+ ).toBe(true);
+
+ const query = makeStream([
+ {
+ type: "result",
+ subtype: "success",
+ duration_ms: 12,
+ duration_api_ms: 12,
+ is_error: true,
+ num_turns: 1,
+ result: "",
+ session_id: "session-401",
+ total_cost_usd: 0,
+ usage: {
+ input_tokens: 0,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ output_tokens: 0,
+ server_tool_use: { web_search_requests: 0 },
+ service_tier: "standard",
+ },
+ errors: [
+ 'API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
+ ],
+ },
+ ]);
+ mockState.query.mockReturnValue(query.stream);
+
+ await probeClaudeRuntimeHealth({ projectRoot: "/tmp/project", force: true });
+
+ expect(query.close).toHaveBeenCalledTimes(1);
+ expect(mockState.reportProviderRuntimeAuthFailure).toHaveBeenCalledTimes(1);
+ expect(mockState.reportProviderRuntimeFailure).not.toHaveBeenCalled();
+ });
});
diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts
index 883d3f7e..f5c73c3b 100644
--- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts
+++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts
@@ -34,11 +34,18 @@ export function isClaudeRuntimeAuthError(input: unknown): boolean {
lower.includes("not authenticated")
|| lower.includes("not logged in")
|| lower.includes("authentication required")
+ || lower.includes("authentication error")
+ || lower.includes("authentication_error")
|| lower.includes("login required")
|| lower.includes("sign in")
|| lower.includes("claude auth login")
|| lower.includes("/login")
|| lower.includes("authentication_failed")
+ || lower.includes("invalid authentication credentials")
+ || lower.includes("invalid api key")
+ || lower.includes("api error: 401")
+ || lower.includes("status code: 401")
+ || lower.includes("status 401")
);
}
diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts
new file mode 100644
index 00000000..a1d595c6
--- /dev/null
+++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts
@@ -0,0 +1,127 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { AiProviderConnections } from "../../../shared/types";
+import type { CliAuthStatus } from "./authDetector";
+
+const mockState = vi.hoisted(() => ({
+ readClaudeCredentials: vi.fn(),
+ readCodexCredentials: vi.fn(),
+ isCodexTokenStale: vi.fn(),
+ getProviderRuntimeHealth: vi.fn(),
+}));
+
+vi.mock("./providerCredentialSources", () => ({
+ readClaudeCredentials: (...args: unknown[]) => mockState.readClaudeCredentials(...args),
+ readCodexCredentials: (...args: unknown[]) => mockState.readCodexCredentials(...args),
+ isCodexTokenStale: (...args: unknown[]) => mockState.isCodexTokenStale(...args),
+}));
+
+vi.mock("./providerRuntimeHealth", () => ({
+ getProviderRuntimeHealth: (...args: unknown[]) => mockState.getProviderRuntimeHealth(...args),
+}));
+
+let buildProviderConnections: (cliStatuses: CliAuthStatus[]) => Promise;
+
+beforeEach(async () => {
+ vi.resetModules();
+ mockState.readClaudeCredentials.mockReset();
+ mockState.readCodexCredentials.mockReset();
+ mockState.isCodexTokenStale.mockReset();
+ mockState.getProviderRuntimeHealth.mockReset();
+
+ mockState.readClaudeCredentials.mockResolvedValue(null);
+ mockState.readCodexCredentials.mockResolvedValue(null);
+ mockState.isCodexTokenStale.mockReturnValue(false);
+ mockState.getProviderRuntimeHealth.mockReturnValue(null);
+
+ ({ buildProviderConnections } = await import("./providerConnectionStatus"));
+});
+
+describe("buildProviderConnections", () => {
+ it("does not mark Claude runtime as connected when the CLI explicitly reports signed out", async () => {
+ mockState.readClaudeCredentials.mockResolvedValue({
+ accessToken: "token",
+ source: "claude-credentials-file",
+ });
+
+ const result = await buildProviderConnections([
+ {
+ cli: "claude",
+ installed: true,
+ path: "/Users/arul/.local/bin/claude",
+ authenticated: false,
+ verified: true,
+ },
+ {
+ cli: "codex",
+ installed: false,
+ path: null,
+ authenticated: false,
+ verified: false,
+ },
+ ]);
+
+ expect(result.claude.authAvailable).toBe(true);
+ expect(result.claude.runtimeDetected).toBe(true);
+ expect(result.claude.runtimeAvailable).toBe(false);
+ expect(result.claude.blocker).toContain("Claude CLI reports no active login");
+ expect(result.claude.blocker).toContain("claude auth login");
+ });
+
+ it("keeps the optimistic local-credentials fallback when CLI auth could not be verified", async () => {
+ mockState.readClaudeCredentials.mockResolvedValue({
+ accessToken: "token",
+ source: "claude-credentials-file",
+ });
+
+ const result = await buildProviderConnections([
+ {
+ cli: "claude",
+ installed: true,
+ path: "/Users/arul/.local/bin/claude",
+ authenticated: false,
+ verified: false,
+ },
+ {
+ cli: "codex",
+ installed: false,
+ path: null,
+ authenticated: false,
+ verified: false,
+ },
+ ]);
+
+ expect(result.claude.authAvailable).toBe(true);
+ expect(result.claude.runtimeAvailable).toBe(true);
+ expect(result.claude.blocker).toBeNull();
+ });
+
+ it("applies the same signed-out guard to Codex when local auth artifacts remain on disk", async () => {
+ mockState.readCodexCredentials.mockResolvedValue({
+ accessToken: "token",
+ source: "codex-auth-file",
+ });
+
+ const result = await buildProviderConnections([
+ {
+ cli: "claude",
+ installed: false,
+ path: null,
+ authenticated: false,
+ verified: false,
+ },
+ {
+ cli: "codex",
+ installed: true,
+ path: "/Users/arul/.local/bin/codex",
+ authenticated: false,
+ verified: true,
+ },
+ ]);
+
+ expect(result.codex.authAvailable).toBe(true);
+ expect(result.codex.runtimeDetected).toBe(true);
+ expect(result.codex.runtimeAvailable).toBe(false);
+ expect(result.codex.blocker).toContain("Codex CLI reports no active login");
+ expect(result.codex.blocker).toContain("codex login");
+ });
+});
diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts
index 028a87ba..bd90edb4 100644
--- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts
+++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts
@@ -38,23 +38,72 @@ export async function buildProviderConnections(
const claudeRuntimeHealth = getProviderRuntimeHealth("claude");
const codexRuntimeHealth = getProviderRuntimeHealth("codex");
- const claudeRuntimeDetected = Boolean(claudeCli?.installed);
- const claudeCliAuthenticated = Boolean(claudeCli?.installed && claudeCli.authenticated);
- const claudeAuthAvailable = Boolean(claudeLocalCreds || claudeCliAuthenticated);
- // Connected = we have auth credentials + the CLI is installed.
- // The runtime probe can only DOWNGRADE this to false on explicit auth failure.
- const claudeRuntimeAvailable = Boolean(claudeAuthAvailable && claudeRuntimeDetected);
+ const deriveProviderFlags = (
+ cli: CliAuthStatus | null,
+ localCreds: Awaited> | Awaited>,
+ ) => {
+ const runtimeDetected = Boolean(cli?.installed);
+ const cliAuthenticated = Boolean(cli?.installed && cli.authenticated);
+ const cliExplicitlyUnauthenticated = Boolean(cli?.installed && cli.verified && !cli.authenticated);
+ const localCredsDetected = Boolean(localCreds);
+ const authAvailable = Boolean(localCreds || cliAuthenticated);
+ // Local credential artifacts are only a fallback signal. If the CLI itself
+ // has already verified that the user is signed out, do not promote the
+ // provider to runtime-ready until the user logs in again.
+ const runtimeAvailable = Boolean(authAvailable && runtimeDetected && !cliExplicitlyUnauthenticated);
+ return { runtimeDetected, cliAuthenticated, cliExplicitlyUnauthenticated, localCredsDetected, authAvailable, runtimeAvailable };
+ };
- const codexRuntimeDetected = Boolean(codexCli?.installed);
- const codexCliAuthenticated = Boolean(codexCli?.installed && codexCli.authenticated);
+ const claudeFlags = deriveProviderFlags(claudeCli, claudeLocalCreds);
+ const codexFlags = deriveProviderFlags(codexCli, codexLocalCreds);
const codexUsageAvailable = Boolean(codexLocalCreds && !isCodexTokenStale(codexLocalCreds));
- const codexAuthAvailable = Boolean(codexLocalCreds || codexCliAuthenticated);
- const codexRuntimeAvailable = Boolean(codexAuthAvailable && codexRuntimeDetected);
+
+ function resolveBlocker(
+ providerLabel: string,
+ loginHint: string,
+ flags: ReturnType,
+ extraBlocker?: string | null,
+ ): string | null {
+ if (!flags.authAvailable && !flags.runtimeDetected) {
+ return `No ${providerLabel} authentication or CLI was found locally.`;
+ }
+ if (flags.cliExplicitlyUnauthenticated) {
+ return flags.localCredsDetected
+ ? `Local ${providerLabel} credentials were found, but ${providerLabel} CLI reports no active login. Run: ${loginHint}`
+ : `${providerLabel} CLI is installed but no login was detected. Run: ${loginHint}`;
+ }
+ if (!flags.authAvailable) {
+ return `${providerLabel} CLI is installed but no login was detected. Run: ${loginHint}`;
+ }
+ if (!flags.runtimeDetected) {
+ return `Local credentials exist but the ${providerLabel} CLI is not on ADE's PATH.`;
+ }
+ if (extraBlocker) return extraBlocker;
+ return null;
+ }
+
+ // Apply runtime health overrides.
+ // Only an explicit auth failure should downgrade status. Transient probe
+ // failures (process abort, timeout) should not block a user with valid creds.
+ function applyRuntimeHealth(
+ status: AiProviderConnectionStatus,
+ health: ReturnType,
+ ): void {
+ if (health?.state === "auth-failed") {
+ status.runtimeAvailable = false;
+ status.blocker = health.message
+ ?? `${status.provider} runtime was detected, but ADE chat reported that login is still required.`;
+ } else if (health?.state === "ready") {
+ status.runtimeAvailable = true;
+ status.authAvailable = true;
+ status.blocker = null;
+ }
+ }
const claude = createUnavailableStatus("claude", checkedAt);
- claude.authAvailable = claudeAuthAvailable;
- claude.runtimeDetected = claudeRuntimeDetected;
- claude.runtimeAvailable = claudeRuntimeAvailable;
+ claude.authAvailable = claudeFlags.authAvailable;
+ claude.runtimeDetected = claudeFlags.runtimeDetected;
+ claude.runtimeAvailable = claudeFlags.runtimeAvailable;
claude.usageAvailable = Boolean(claudeLocalCreds);
claude.path = claudeCli?.path ?? null;
claude.sources = [
@@ -71,34 +120,13 @@ export async function buildProviderConnections(
path: claudeCli?.path ?? null,
},
];
- if (!claudeAuthAvailable && !claudeRuntimeDetected) {
- claude.blocker = "No Claude authentication or CLI was found locally.";
- } else if (!claudeAuthAvailable) {
- claude.blocker = "Claude CLI is installed but no login was detected. Run: claude auth login";
- } else if (!claudeRuntimeDetected) {
- claude.blocker = "Local credentials exist but the Claude CLI is not on ADE's PATH.";
- } else {
- claude.blocker = null;
- }
- // Only an explicit auth failure from the runtime probe should downgrade status.
- // Transient failures (aborted, timeout, exit code 1) should NOT override
- // the presence of valid local credentials + installed CLI.
- if (claudeRuntimeHealth?.state === "auth-failed") {
- claude.runtimeAvailable = false;
- claude.blocker = claudeRuntimeHealth.message
- ?? "Claude runtime was detected, but ADE chat reported that login is still required.";
- } else if (claudeRuntimeHealth?.state === "ready") {
- claude.runtimeAvailable = true;
- claude.authAvailable = true;
- claude.blocker = null;
- }
- // Note: "runtime-failed" is deliberately ignored — transient probe failures
- // (process abort, timeout) should not block a user who has valid credentials.
+ claude.blocker = resolveBlocker("Claude", "claude auth login", claudeFlags);
+ applyRuntimeHealth(claude, claudeRuntimeHealth);
const codex = createUnavailableStatus("codex", checkedAt);
- codex.authAvailable = codexAuthAvailable;
- codex.runtimeDetected = codexRuntimeDetected;
- codex.runtimeAvailable = codexRuntimeAvailable;
+ codex.authAvailable = codexFlags.authAvailable;
+ codex.runtimeDetected = codexFlags.runtimeDetected;
+ codex.runtimeAvailable = codexFlags.runtimeAvailable;
codex.usageAvailable = codexUsageAvailable;
codex.path = codexCli?.path ?? null;
codex.sources = [
@@ -116,26 +144,15 @@ export async function buildProviderConnections(
path: codexCli?.path ?? null,
},
];
- if (!codexAuthAvailable && !codexRuntimeDetected) {
- codex.blocker = "No Codex authentication or CLI was found locally.";
- } else if (!codexAuthAvailable) {
- codex.blocker = "Codex CLI is installed but no login was detected. Run: codex login";
- } else if (!codexRuntimeDetected) {
- codex.blocker = "Local credentials exist but the Codex CLI is not on ADE's PATH.";
- } else if (codexLocalCreds && isCodexTokenStale(codexLocalCreds)) {
- codex.blocker = "Codex local auth exists, but the stored token looks stale for usage polling.";
- } else {
- codex.blocker = null;
- }
- if (codexRuntimeHealth?.state === "auth-failed") {
- codex.runtimeAvailable = false;
- codex.blocker = codexRuntimeHealth.message
- ?? "Codex runtime was detected, but ADE chat reported that login is still required.";
- } else if (codexRuntimeHealth?.state === "ready") {
- codex.runtimeAvailable = true;
- codex.authAvailable = true;
- codex.blocker = null;
- }
+ codex.blocker = resolveBlocker(
+ "Codex",
+ "codex login",
+ codexFlags,
+ codexLocalCreds && isCodexTokenStale(codexLocalCreds)
+ ? "Codex local auth exists, but the stored token looks stale for usage polling."
+ : null,
+ );
+ applyRuntimeHealth(codex, codexRuntimeHealth);
return { claude, codex };
}
diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts
index 65b5d897..31e6a328 100644
--- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts
+++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts
@@ -42,6 +42,9 @@ function buildDeps(overrides: Partial = {}): CtoOperatorToo
workerHeartbeatService: null,
linearDispatcherService: null,
flowPolicyService: null,
+ prService: null,
+ fileService: null,
+ processService: null,
issueTracker: null,
listChats: vi.fn().mockResolvedValue([]),
getChatStatus: vi.fn().mockResolvedValue(null),
@@ -55,8 +58,9 @@ function buildDeps(overrides: Partial = {}): CtoOperatorToo
updateChatSession: vi.fn().mockResolvedValue(baseSession),
sendChatMessage: vi.fn().mockResolvedValue(undefined),
interruptChat: vi.fn().mockResolvedValue(undefined),
+ resumeChat: vi.fn().mockResolvedValue(baseSession),
+ disposeChat: vi.fn().mockResolvedValue(undefined),
ensureCtoSession: vi.fn().mockResolvedValue({ ...baseSession, id: "cto-session" }),
- fetchMissionContext: vi.fn().mockResolvedValue({ content: "ctx", truncated: false }),
...overrides,
};
}
@@ -103,7 +107,8 @@ describe("createCtoOperatorTools", () => {
expect(result).toMatchObject({
success: true,
sessionId: "chat-1",
- navigation: { surface: "lanes", laneId: "lane-1", sessionId: "chat-1" },
+ navigation: { surface: "work", laneId: "lane-1", sessionId: "chat-1", href: "/work?laneId=lane-1&sessionId=chat-1" },
+ navigationSuggestions: [{ surface: "work", laneId: "lane-1", sessionId: "chat-1", href: "/work?laneId=lane-1&sessionId=chat-1" }],
requestedTitle: "Backend follow-up",
});
});
@@ -166,6 +171,39 @@ describe("createCtoOperatorTools", () => {
expect(resolved).toMatchObject({ success: true, intervention });
});
+ it("returns lane and mission navigation suggestions for operator-created ADE objects", async () => {
+ const lane = { id: "lane-2", name: "ops", branchRef: "refs/heads/ops" };
+ const mission = { id: "mission-7", title: "Mission", laneId: "lane-2" };
+ const deps = buildDeps({
+ laneService: {
+ list: vi.fn().mockResolvedValue([lane]),
+ create: vi.fn().mockResolvedValue(lane),
+ } as any,
+ missionService: {
+ create: vi.fn().mockReturnValue(mission),
+ } as any,
+ });
+ const tools = createCtoOperatorTools(deps);
+
+ const createdLane = await (tools.createLane as any).execute({
+ name: "ops",
+ });
+ const startedMission = await (tools.startMission as any).execute({
+ prompt: "Investigate the failing deploy path.",
+ laneId: "lane-2",
+ launch: false,
+ });
+
+ expect(createdLane).toMatchObject({
+ success: true,
+ navigation: { surface: "lanes", laneId: "lane-2", href: "/lanes?laneId=lane-2" },
+ });
+ expect(startedMission).toMatchObject({
+ success: true,
+ navigation: { surface: "missions", laneId: "lane-2", missionId: "mission-7", href: "/missions?missionId=mission-7&laneId=lane-2" },
+ });
+ });
+
it("surfaces mission runtime view, logs, worker digests, and steering through aiOrchestratorService", async () => {
const runView = { missionId: "mission-1", displayStatus: "running" };
const logs = { entries: [{ id: "log-1" }], nextCursor: null, total: 1 };
@@ -328,7 +366,7 @@ describe("createCtoOperatorTools", () => {
cancelledExistingRun: false,
rerouted: {
success: true,
- navigation: { surface: "lanes", laneId: "lane-1", sessionId: "cto-recovery" },
+ navigation: { surface: "cto", laneId: "lane-1", sessionId: "cto-recovery", href: "/cto" },
},
});
});
diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts
index 4447a621..cc6d46ac 100644
--- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts
+++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts
@@ -11,15 +11,19 @@ import type {
AgentUpsertInput,
CtoTriggerAgentWakeupArgs,
LinearWorkflowConfig,
+ OperatorNavigationSuggestion,
} from "../../../../shared/types";
import type { IssueTracker } from "../../cto/issueTracker";
import type { createLinearDispatcherService } from "../../cto/linearDispatcherService";
import type { createWorkerAgentService } from "../../cto/workerAgentService";
import type { createWorkerHeartbeatService } from "../../cto/workerHeartbeatService";
import type { createFlowPolicyService } from "../../cto/flowPolicyService";
+import type { createFileService } from "../../files/fileService";
import type { createLaneService } from "../../lanes/laneService";
import type { createMissionService } from "../../missions/missionService";
import type { createAiOrchestratorService } from "../../orchestrator/aiOrchestratorService";
+import type { createPrService } from "../../prs/prService";
+import type { createProcessService } from "../../processes/processService";
export interface CtoOperatorToolDeps {
currentSessionId: string;
@@ -33,6 +37,9 @@ export interface CtoOperatorToolDeps {
workerHeartbeatService?: ReturnType | null;
linearDispatcherService?: ReturnType | null;
flowPolicyService?: ReturnType | null;
+ prService?: ReturnType | null;
+ fileService?: ReturnType | null;
+ processService?: ReturnType | null;
issueTracker?: IssueTracker | null;
listChats: (laneId?: string, options?: { includeIdentity?: boolean; includeAutomation?: boolean }) => Promise;
getChatStatus: (sessionId: string) => Promise;
@@ -58,21 +65,16 @@ export interface CtoOperatorToolDeps {
}) => Promise;
sendChatMessage: (args: AgentChatSendArgs) => Promise;
interruptChat: (args: AgentChatInterruptArgs) => Promise;
+ resumeChat: (args: { sessionId: string }) => Promise;
+ disposeChat: (args: { sessionId: string }) => Promise;
ensureCtoSession: (args: {
laneId: string;
modelId?: string | null;
reasoningEffort?: string | null;
reuseExisting?: boolean;
}) => Promise;
- fetchMissionContext?: (missionId: string) => Promise<{ content: string; truncated: boolean }>;
}
-type ChatNavigationHint = {
- surface: "lanes";
- laneId: string;
- sessionId: string;
-};
-
const ACTIVE_LINEAR_RUN_STATUSES = new Set([
"queued",
"in_progress",
@@ -128,14 +130,99 @@ function summarizeWorkerStatus(status: AgentStatus): string {
}
}
-function buildChatNavigationHint(session: Pick): ChatNavigationHint {
+function buildNavigationSuggestion(args: {
+ surface: OperatorNavigationSuggestion["surface"];
+ laneId?: string | null;
+ sessionId?: string | null;
+ missionId?: string | null;
+}): OperatorNavigationSuggestion {
+ const laneId = args.laneId?.trim() || null;
+ const sessionId = args.sessionId?.trim() || null;
+ const missionId = args.missionId?.trim() || null;
+ if (args.surface === "work") {
+ const search = new URLSearchParams();
+ if (laneId) search.set("laneId", laneId);
+ if (sessionId) search.set("sessionId", sessionId);
+ return {
+ surface: "work",
+ label: "Open in Work",
+ href: `/work${search.size ? `?${search.toString()}` : ""}`,
+ laneId,
+ sessionId,
+ };
+ }
+ if (args.surface === "missions") {
+ const search = new URLSearchParams();
+ if (missionId) search.set("missionId", missionId);
+ if (laneId) search.set("laneId", laneId);
+ return {
+ surface: "missions",
+ label: "Open mission",
+ href: `/missions${search.size ? `?${search.toString()}` : ""}`,
+ laneId,
+ missionId,
+ };
+ }
+ if (args.surface === "cto") {
+ return {
+ surface: "cto",
+ label: "Open CTO",
+ href: "/cto",
+ laneId,
+ sessionId,
+ };
+ }
+ const search = new URLSearchParams();
+ if (laneId) search.set("laneId", laneId);
+ if (sessionId) search.set("sessionId", sessionId);
return {
surface: "lanes",
- laneId: session.laneId,
- sessionId: session.id,
+ label: "Open lane",
+ href: `/lanes${search.size ? `?${search.toString()}` : ""}`,
+ laneId,
+ sessionId,
+ };
+}
+
+function buildNavigationPayload(
+ suggestion: OperatorNavigationSuggestion | null,
+ includeSuggestions = true,
+): {
+ navigation?: OperatorNavigationSuggestion;
+ navigationSuggestions?: OperatorNavigationSuggestion[];
+} {
+ if (!includeSuggestions || !suggestion) return {};
+ return {
+ navigation: suggestion,
+ navigationSuggestions: [suggestion],
};
}
+function resolveWorkspaceIdForLane(
+ deps: Pick,
+ args: { workspaceId?: string | null; laneId?: string | null },
+): string {
+ if (!deps.fileService) {
+ throw new Error("File service is not available.");
+ }
+ const allWorkspaces = deps.fileService.listWorkspaces({ includeArchived: true });
+ const explicitWorkspaceId = args.workspaceId?.trim() || "";
+ if (explicitWorkspaceId) {
+ const workspace = allWorkspaces.find((entry) => entry.id === explicitWorkspaceId) ?? null;
+ if (!workspace) throw new Error(`Workspace not found: ${explicitWorkspaceId}`);
+ return workspace.id;
+ }
+ const laneId = args.laneId?.trim() || deps.defaultLaneId;
+ // Prefer active workspaces; fall back to archived only if no active match.
+ const activeWorkspaces = deps.fileService.listWorkspaces({ includeArchived: false });
+ const laneWorkspace =
+ activeWorkspaces.find((entry) => entry.laneId === laneId) ??
+ allWorkspaces.find((entry) => entry.laneId === laneId) ??
+ null;
+ if (laneWorkspace) return laneWorkspace.id;
+ throw new Error(`Workspace not found for lane ${laneId}.`);
+}
+
export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record {
const tools: Record = {};
@@ -169,7 +256,11 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record {
try {
const lane = await deps.laneService.create({ name, description, parentLaneId });
- return { success: true, lane };
+ return {
+ success: true,
+ lane,
+ ...buildNavigationPayload(buildNavigationSuggestion({
+ surface: "lanes",
+ laneId: lane.id,
+ })),
+ };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
@@ -365,7 +479,11 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record {
try {
@@ -395,7 +513,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record {
try {
@@ -407,10 +525,50 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record {
+ try {
+ const session = await deps.resumeChat({ sessionId });
+ return {
+ success: true,
+ sessionId: session.id,
+ laneId: session.laneId,
+ status: session.status,
+ ...buildNavigationPayload(buildNavigationSuggestion({
+ surface: "work",
+ laneId: session.laneId,
+ sessionId: session.id,
+ })),
+ };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
+ tools.endChat = tool({
+ description: "End and archive an ADE chat session.",
+ inputSchema: z.object({
+ sessionId: z.string().trim().min(1),
+ }),
+ execute: async ({ sessionId }) => {
+ try {
+ await deps.disposeChat({ sessionId });
+ return { success: true, sessionId };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
tools.getChatStatus = tool({
description: "Get the current status for an ADE chat session.",
inputSchema: z.object({
- sessionId: z.string(),
+ sessionId: z.string().trim().min(1),
}),
execute: async ({ sessionId }) => {
const session = await deps.getChatStatus(sessionId);
@@ -489,7 +647,16 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record {
- if (!deps.fetchMissionContext) return { success: false, error: "Mission context export is not available." };
- try {
- const result = await deps.fetchMissionContext(missionId);
- return { success: true, missionId, ...result };
- } catch (error) {
- return { success: false, error: error instanceof Error ? error.message : String(error) };
- }
- },
- });
-
tools.listWorkers = tool({
description: "List worker agents in the CTO org.",
inputSchema: z.object({
@@ -819,6 +995,268 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record {
+ if (!deps.prService) return { success: false, error: "PR service is not available." };
+ try {
+ const prs = refresh ? await deps.prService.refresh() : deps.prService.listAll();
+ return { success: true, count: prs.length, prs };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
+ tools.getPullRequestStatus = tool({
+ description: "Inspect pull request status, checks, reviews, and comments through ADE's PR service.",
+ inputSchema: z.object({
+ prId: z.string().trim().min(1),
+ includeChecks: z.boolean().optional().default(true),
+ includeReviews: z.boolean().optional().default(true),
+ includeComments: z.boolean().optional().default(false),
+ }),
+ execute: async ({ prId, includeChecks, includeReviews, includeComments }) => {
+ if (!deps.prService) return { success: false, error: "PR service is not available." };
+ try {
+ const summary = deps.prService.listAll().find((entry) => entry.id === prId) ?? null;
+ const [status, checks, reviews, comments] = await Promise.all([
+ deps.prService.getStatus(prId),
+ includeChecks ? deps.prService.getChecks(prId) : Promise.resolve([]),
+ includeReviews ? deps.prService.getReviews(prId) : Promise.resolve([]),
+ includeComments ? deps.prService.getComments(prId) : Promise.resolve([]),
+ ]);
+ return {
+ success: true,
+ prId,
+ summary,
+ status,
+ checks,
+ reviews,
+ comments,
+ };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
+ tools.commentOnPullRequest = tool({
+ description: "Post a comment to a pull request through ADE's PR service.",
+ inputSchema: z.object({
+ prId: z.string().trim().min(1),
+ body: z.string().trim().min(1),
+ }),
+ execute: async ({ prId, body }) => {
+ if (!deps.prService) return { success: false, error: "PR service is not available." };
+ try {
+ const comment = await deps.prService.addComment({ prId, body });
+ return { success: true, comment };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
+ tools.updatePullRequestTitle = tool({
+ description: "Update a pull request title through ADE's PR service.",
+ inputSchema: z.object({
+ prId: z.string().trim().min(1),
+ title: z.string().trim().min(1),
+ }),
+ execute: async ({ prId, title }) => {
+ if (!deps.prService) return { success: false, error: "PR service is not available." };
+ try {
+ await deps.prService.updateTitle({ prId, title });
+ return { success: true, prId, title };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
+ tools.updatePullRequestBody = tool({
+ description: "Update a pull request body through ADE's PR service.",
+ inputSchema: z.object({
+ prId: z.string().trim().min(1),
+ body: z.string().min(1),
+ }),
+ execute: async ({ prId, body }) => {
+ if (!deps.prService) return { success: false, error: "PR service is not available." };
+ try {
+ await deps.prService.updateDescription({ prId, body });
+ return { success: true, prId };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
+ tools.listFileWorkspaces = tool({
+ description: "List ADE file workspaces so the CTO can inspect files by lane or attached workspace.",
+ inputSchema: z.object({
+ includeArchived: z.boolean().optional().default(true),
+ }),
+ execute: async ({ includeArchived }) => {
+ if (!deps.fileService) return { success: false, error: "File service is not available." };
+ const workspaces = deps.fileService.listWorkspaces({ includeArchived });
+ return { success: true, count: workspaces.length, workspaces };
+ },
+ });
+
+ tools.readWorkspaceFile = tool({
+ description: "Read a file from an ADE workspace or lane without opening the renderer editor.",
+ inputSchema: z.object({
+ workspaceId: z.string().trim().min(1).optional(),
+ laneId: z.string().trim().min(1).optional(),
+ path: z.string().trim().min(1),
+ }),
+ execute: async ({ workspaceId, laneId, path }) => {
+ if (!deps.fileService) return { success: false, error: "File service is not available." };
+ try {
+ const resolvedWorkspaceId = resolveWorkspaceIdForLane(deps, {
+ workspaceId,
+ laneId,
+ });
+ const file = deps.fileService.readFile({ workspaceId: resolvedWorkspaceId, path });
+ return {
+ success: true,
+ workspaceId: resolvedWorkspaceId,
+ path,
+ file,
+ };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
+ tools.searchWorkspaceText = tool({
+ description: "Search indexed text inside an ADE workspace or lane.",
+ inputSchema: z.object({
+ workspaceId: z.string().trim().min(1).optional(),
+ laneId: z.string().trim().min(1).optional(),
+ query: z.string().trim().min(1),
+ limit: z.number().int().positive().max(200).optional().default(50),
+ }),
+ execute: async ({ workspaceId, laneId, query, limit }) => {
+ if (!deps.fileService) return { success: false, error: "File service is not available." };
+ try {
+ const resolvedWorkspaceId = resolveWorkspaceIdForLane(deps, {
+ workspaceId,
+ laneId,
+ });
+ const matches = await deps.fileService.searchText({
+ workspaceId: resolvedWorkspaceId,
+ query,
+ limit,
+ });
+ return {
+ success: true,
+ workspaceId: resolvedWorkspaceId,
+ count: matches.length,
+ matches,
+ };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
+ tools.listManagedProcesses = tool({
+ description: "Inspect ADE-managed processes for a lane, including configured definitions and current runtime state.",
+ inputSchema: z.object({
+ laneId: z.string().optional(),
+ }),
+ execute: async ({ laneId }) => {
+ if (!deps.processService) return { success: false, error: "Process service is not available." };
+ const resolvedLaneId = laneId?.trim() || deps.defaultLaneId;
+ try {
+ const [definitions, runtime] = await Promise.all([
+ Promise.resolve(deps.processService.listDefinitions()),
+ Promise.resolve(deps.processService.listRuntime(resolvedLaneId)),
+ ]);
+ return {
+ success: true,
+ laneId: resolvedLaneId,
+ definitions,
+ runtime,
+ ...buildNavigationPayload(buildNavigationSuggestion({
+ surface: "lanes",
+ laneId: resolvedLaneId,
+ })),
+ };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
+ tools.startManagedProcess = tool({
+ description: "Start an ADE-managed lane process.",
+ inputSchema: z.object({
+ laneId: z.string().trim().min(1).optional(),
+ processId: z.string().trim().min(1),
+ }),
+ execute: async ({ laneId, processId }) => {
+ if (!deps.processService) return { success: false, error: "Process service is not available." };
+ try {
+ const runtime = await deps.processService.start({
+ laneId: laneId?.trim() || deps.defaultLaneId,
+ processId,
+ });
+ return { success: true, runtime };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
+ tools.stopManagedProcess = tool({
+ description: "Stop an ADE-managed lane process.",
+ inputSchema: z.object({
+ laneId: z.string().trim().min(1).optional(),
+ processId: z.string().trim().min(1),
+ }),
+ execute: async ({ laneId, processId }) => {
+ if (!deps.processService) return { success: false, error: "Process service is not available." };
+ try {
+ const runtime = await deps.processService.stop({
+ laneId: laneId?.trim() || deps.defaultLaneId,
+ processId,
+ });
+ return { success: true, runtime };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
+ tools.getManagedProcessLog = tool({
+ description: "Read the bounded tail of an ADE-managed process log.",
+ inputSchema: z.object({
+ laneId: z.string().trim().min(1).optional(),
+ processId: z.string().trim().min(1),
+ maxBytes: z.number().int().positive().max(500_000).optional().default(40_000),
+ }),
+ execute: async ({ laneId, processId, maxBytes }) => {
+ if (!deps.processService) return { success: false, error: "Process service is not available." };
+ try {
+ const content = deps.processService.getLogTail({
+ laneId: laneId?.trim() || deps.defaultLaneId,
+ processId,
+ maxBytes,
+ });
+ return { success: true, laneId: laneId?.trim() || deps.defaultLaneId, processId, content };
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ },
+ });
+
tools.listLinearWorkflows = tool({
description: "List active and queued Linear workflow runs managed by ADE.",
inputSchema: z.object({}),
diff --git a/apps/desktop/src/main/services/ai/tools/index.ts b/apps/desktop/src/main/services/ai/tools/index.ts
index 5313d253..37514ec6 100644
--- a/apps/desktop/src/main/services/ai/tools/index.ts
+++ b/apps/desktop/src/main/services/ai/tools/index.ts
@@ -33,4 +33,3 @@ export function createCodingToolSet(
export { createUniversalToolSet } from "./universalTools";
export type { PermissionMode, UniversalToolSetOptions } from "./universalTools";
export { buildCodingAgentSystemPrompt, composeSystemPrompt } from "./systemPrompt";
-export { loadMcpTools } from "./mcpBridge";
diff --git a/apps/desktop/src/main/services/ai/tools/mcpBridge.ts b/apps/desktop/src/main/services/ai/tools/mcpBridge.ts
deleted file mode 100644
index b725baed..00000000
--- a/apps/desktop/src/main/services/ai/tools/mcpBridge.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * MCP client integration stub.
- * When @ai-sdk/mcp is installed, this module will bridge MCP servers
- * into the Vercel AI SDK tool format.
- */
-export async function loadMcpTools(
- _mcpConfig?: Record
-): Promise> {
- // Stub: MCP tool integration will be added when @ai-sdk/mcp is installed.
- // For now, return an empty tool set.
- return {};
-}
diff --git a/apps/desktop/src/main/services/ai/tools/teamMessageTool.ts b/apps/desktop/src/main/services/ai/tools/teamMessageTool.ts
deleted file mode 100644
index 15113d0f..00000000
--- a/apps/desktop/src/main/services/ai/tools/teamMessageTool.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { z } from "zod";
-import { tool } from "ai";
-
-export function createTeamMessageTool(opts: {
- sendCallback: (args: { target: string; message: string; fromAttemptId: string }) => Promise;
- currentAttemptId: string;
-}) {
- return tool({
- description:
- "Send a message to another agent or the orchestrator. Use @step-key to target a specific agent, @orchestrator for the orchestrator, or @all for broadcast.",
- inputSchema: z.object({
- target: z.string().describe("Target: step-key, 'orchestrator', or 'all'"),
- message: z.string().describe("The message content"),
- }),
- execute: async ({ target, message }) => {
- await opts.sendCallback({
- target,
- message,
- fromAttemptId: opts.currentAttemptId,
- });
- return { delivered: true, target };
- },
- });
-}
diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts
index 17a4a3af..fab25f52 100644
--- a/apps/desktop/src/main/services/automations/automationPlannerService.ts
+++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts
@@ -222,12 +222,6 @@ function buildPlannerSchema(): Record {
minItems: 1,
items: {
oneOf: [
- {
- type: "object",
- additionalProperties: false,
- properties: { type: { const: "update-packs" }, ...baseActionProps },
- required: ["type"]
- },
{
type: "object",
additionalProperties: false,
@@ -291,7 +285,6 @@ function buildPlannerPrompt(args: {
"- linear.issue_status_changed",
"",
"Available actions:",
- "- update-packs",
"- predict-conflicts",
"- run-tests (requires suite string; use a suite id or name below)",
"- run-command (requires command string; keep it a single shell command)",
@@ -608,7 +601,6 @@ function normalizeDraft(args: {
} satisfies Partial;
if (
- type !== "update-packs" &&
type !== "predict-conflicts" &&
type !== "run-tests" &&
type !== "run-command"
@@ -1186,9 +1178,6 @@ export function createAutomationPlannerService({
warnings
};
}
- if (action.type === "update-packs") {
- return { index, type: action.type, summary: "Refresh packs (lane packs + project pack)", warnings };
- }
if (action.type === "predict-conflicts") {
return { index, type: action.type, summary: "Run conflict prediction", warnings };
}
diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts
index 2301c252..27c52307 100644
--- a/apps/desktop/src/main/services/automations/automationService.ts
+++ b/apps/desktop/src/main/services/automations/automationService.ts
@@ -1398,9 +1398,6 @@ export function createAutomationService({
if (raw === "provider-enabled" && (projectConfigService.get().effective.providerMode ?? "guest") === "guest") {
return { status: "skipped", output: "Provider mode disabled." };
}
- if (action.type === "update-packs") {
- return { status: "succeeded", output: "update-packs is deprecated; unified memory lifecycle runs automatically." };
- }
if (action.type === "predict-conflicts") {
if (!conflictService) throw new Error("Conflict service unavailable");
await conflictService.runPrediction(trigger.laneId ? { laneId: trigger.laneId } : {});
diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts
index 169919d0..fd05e1d5 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.ts
@@ -27,7 +27,8 @@ import type { Logger } from "../logging/logger";
import type { createLaneService } from "../lanes/laneService";
import type { createSessionService } from "../sessions/sessionService";
import type { createProjectConfigService } from "../config/projectConfigService";
-import type { createPackService } from "../packs/packService";
+import type { createFileService } from "../files/fileService";
+import type { createProcessService } from "../processes/processService";
import { runGit } from "../git/git";
import { CLAUDE_RUNTIME_AUTH_ERROR, isClaudeRuntimeAuthError } from "../ai/claudeRuntimeProbe";
import { nowIso, fileSizeOrZero } from "../shared/utils";
@@ -196,6 +197,8 @@ type ClaudeRuntime = {
v2StreamGen: AsyncGenerator | null;
/** Resolves when the subprocess is initialized (system:init received). */
v2WarmupDone: Promise | null;
+ /** Resolves the current warmup race so waiters can stop blocking immediately. */
+ v2WarmupCancel: (() => void) | null;
/** Set to true when teardown runs to cancel an in-flight warmup. */
v2WarmupCancelled: boolean;
activeSubagents: Map;
@@ -205,6 +208,8 @@ type ClaudeRuntime = {
pendingSteers: string[];
approvals: Map;
interrupted: boolean;
+ /** Set when a reasoning effort change is requested mid-turn; flushed when idle. */
+ pendingSessionReset?: boolean;
};
type PendingUnifiedApproval = {
@@ -300,6 +305,15 @@ type SessionTurnCollector = {
timeout: NodeJS.Timeout;
};
+type PreparedSendMessage = {
+ sessionId: string;
+ managed: ManagedChatSession;
+ promptText: string;
+ visibleText: string;
+ attachments: AgentChatFileRef[];
+ reasoningEffort?: string | null;
+};
+
type ResolvedChatConfig = {
codexApprovalPolicy: "untrusted" | "on-request" | "on-failure" | "never";
codexSandboxMode: "read-only" | "workspace-write" | "danger-full-access";
@@ -350,6 +364,7 @@ const CLAUDE_REASONING_EFFORTS: Array<{ effort: string; description: string }> =
{ effort: "low", description: "Quick responses with minimal reasoning." },
{ effort: "medium", description: "Balanced reasoning depth and speed." },
{ effort: "high", description: "Deep reasoning for complex tasks." },
+ { effort: "max", description: "Maximum reasoning depth. Best for Opus on hard problems." },
];
const CLAUDE_EFFORT_TO_TOKENS: Record = {
@@ -445,7 +460,7 @@ const KNOWN_CODEX_EFFORTS = new Set(CODEX_REASONING_EFFORTS.map((e) => e.effort)
const EFFORT_ALIASES: Record> = {
codex: { minimal: "low", max: "xhigh", none: "low" },
- claude: { max: "high" },
+ claude: {},
};
function validateReasoningEffort(provider: "codex" | "claude", effort: string | null | undefined): string | null {
@@ -541,6 +556,10 @@ function buildPlanningApprovalViolation(toolName: string): string {
return `PLANNER CONTRACT VIOLATION: '${toolName}' requested a provider-native approval flow during a planning step. Planning workers must stay inspect-only and return the plan via report_result instead.`;
}
+function isBackgroundTask(item: Record): boolean {
+ return !!(item.run_in_background || item.background);
+}
+
function normalizePreview(text: string, maxChars = 220): string | null {
const lines = text
.split(/\r?\n/)
@@ -987,7 +1006,7 @@ export function createAgentChatService(args: {
transcriptsDir: string;
projectId?: string;
memoryService?: ReturnType | null;
- packService?: ReturnType | null;
+ fileService?: ReturnType | null;
episodicSummaryService?: EpisodicSummaryService | null;
ctoStateService?: ReturnType | null;
workerAgentService?: ReturnType | null;
@@ -1000,6 +1019,7 @@ export function createAgentChatService(args: {
linearClient?: import("../cto/linearClient").LinearClient | null;
linearCredentials?: import("../cto/linearCredentialService").LinearCredentialService | null;
prService?: ReturnType | null;
+ processService?: ReturnType | null;
computerUseArtifactBrokerService?: ComputerUseArtifactBrokerService | null;
laneService: ReturnType;
sessionService: ReturnType;
@@ -1014,7 +1034,7 @@ export function createAgentChatService(args: {
transcriptsDir,
projectId,
memoryService,
- packService,
+ fileService,
episodicSummaryService,
ctoStateService,
workerAgentService,
@@ -1027,6 +1047,7 @@ export function createAgentChatService(args: {
linearClient: linearClientRef,
linearCredentials: linearCredentialsRef,
prService,
+ processService,
computerUseArtifactBrokerService,
laneService,
sessionService,
@@ -1064,7 +1085,7 @@ export function createAgentChatService(args: {
};
const trackSubagentEvent = (managed: ManagedChatSession, event: AgentChatEvent): void => {
- if (event.type !== "subagent_started" && event.type !== "subagent_result") return;
+ if (event.type !== "subagent_started" && event.type !== "subagent_progress" && event.type !== "subagent_result") return;
const map = ensureSubagentSnapshotMap(managed.session.id);
if (event.type === "subagent_started") {
map.set(event.taskId, {
@@ -1076,6 +1097,20 @@ export function createAgentChatService(args: {
});
return;
}
+ if (event.type === "subagent_progress") {
+ const previous = map.get(event.taskId);
+ map.set(event.taskId, {
+ taskId: event.taskId,
+ description: event.description?.trim() || previous?.description || "Subagent task",
+ status: "running",
+ turnId: event.turnId ?? previous?.turnId,
+ startTimestamp: previous?.startTimestamp ?? nowIso(),
+ summary: event.summary.trim() || previous?.summary,
+ lastToolName: event.lastToolName ?? previous?.lastToolName,
+ usage: event.usage ?? previous?.usage,
+ });
+ return;
+ }
const previous = map.get(event.taskId);
const status = event.status === "failed"
? "failed"
@@ -1090,6 +1125,7 @@ export function createAgentChatService(args: {
startTimestamp: previous?.startTimestamp,
endTimestamp: nowIso(),
summary: event.summary ?? previous?.summary,
+ lastToolName: previous?.lastToolName,
usage: event.usage ?? previous?.usage,
});
};
@@ -1948,7 +1984,7 @@ export function createAgentChatService(args: {
managed.runtime = null;
}
if (managed.runtime?.kind === "claude") {
- managed.runtime.v2WarmupCancelled = true;
+ cancelClaudeWarmup(managed, managed.runtime, "teardown");
managed.runtime.activeQuery?.close();
managed.runtime.activeQuery = null;
try { managed.runtime.v2Session?.close(); } catch { /* ignore */ }
@@ -2432,6 +2468,19 @@ export function createAgentChatService(args: {
let assistantText = "";
let usage: { inputTokens?: number | null; outputTokens?: number | null; cacheReadTokens?: number | null; cacheCreationTokens?: number | null } | undefined;
let costUsd: number | null = null;
+ const turnStartedAt = Date.now();
+ let firstStreamEventLogged = false;
+ const markFirstStreamEvent = (kind: string): void => {
+ if (firstStreamEventLogged) return;
+ firstStreamEventLogged = true;
+ logger.info("agent_chat.turn_first_event", {
+ sessionId: managed.session.id,
+ provider: "claude",
+ turnId,
+ kind,
+ latencyMs: Date.now() - turnStartedAt,
+ });
+ };
try {
const claudeDescriptor = resolveSessionModelDescriptor(managed.session);
@@ -2440,7 +2489,20 @@ export function createAgentChatService(args: {
// ── V2 persistent session with background pre-warming ──
// The pre-warm was kicked off in ensureClaudeSessionRuntime. Wait for it.
if (runtime.v2WarmupDone) {
+ const warmupWaitStartedAt = Date.now();
+ logger.info("agent_chat.claude_v2_turn_waiting_for_warmup", {
+ sessionId: managed.session.id,
+ turnId,
+ });
await runtime.v2WarmupDone;
+ logger.info("agent_chat.claude_v2_turn_warmup_wait_done", {
+ sessionId: managed.session.id,
+ turnId,
+ waitedMs: Date.now() - warmupWaitStartedAt,
+ });
+ }
+ if (runtime.interrupted) {
+ throw new Error("Claude turn interrupted during warmup.");
}
// Fallback: if pre-warm failed or didn't run, create session on the fly
if (!runtime.v2Session) {
@@ -2467,6 +2529,7 @@ export function createAgentChatService(args: {
for await (const msg of runtime.v2Session.stream()) {
if (runtime.interrupted) break;
+ markFirstStreamEvent(msg.type);
// Capture session_id from any message
if (!runtime.sdkSessionId && (msg as any).session_id) {
@@ -2479,20 +2542,14 @@ export function createAgentChatService(args: {
const initMsg = msg as any;
runtime.sdkSessionId = initMsg.session_id ?? runtime.sdkSessionId;
if (Array.isArray(initMsg.slash_commands)) {
- runtime.slashCommands = initMsg.slash_commands
- .filter((cmd: unknown) => typeof cmd === "string" && cmd.length > 0)
- .map((cmd: string) => ({ name: cmd.startsWith("/") ? cmd : `/${cmd}`, description: "" }));
+ applyClaudeSlashCommands(runtime, initMsg.slash_commands);
}
try {
const sessionImpl = runtime.v2Session as any;
if (typeof sessionImpl?.supportedCommands === "function") {
sessionImpl.supportedCommands().then((cmds: any[]) => {
if (Array.isArray(cmds) && cmds.length > 0) {
- runtime.slashCommands = cmds.map((c: any) => ({
- name: typeof c.name === "string" ? (c.name.startsWith("/") ? c.name : `/${c.name}`) : String(c),
- description: typeof c.description === "string" ? c.description : "",
- argumentHint: typeof c.argumentHint === "string" ? c.argumentHint : undefined,
- }));
+ applyClaudeSlashCommands(runtime, cmds);
}
}).catch(() => { /* not available */ });
}
@@ -2606,6 +2663,30 @@ export function createAgentChatService(args: {
continue;
}
+ // system:task_progress — running subagent summary/usage
+ if (msg.type === "system" && (msg as any).subtype === "task_progress") {
+ const taskMsg = msg as any;
+ const taskId = String(taskMsg.task_id ?? "");
+ if (!taskId) continue;
+ const existing = runtime.activeSubagents.get(taskId);
+ const description = String(taskMsg.description ?? existing?.description ?? "");
+ runtime.activeSubagents.set(taskId, { taskId, description });
+ emitChatEvent(managed, {
+ type: "subagent_progress",
+ taskId,
+ description,
+ summary: String(taskMsg.summary ?? ""),
+ usage: taskMsg.usage ? {
+ totalTokens: typeof taskMsg.usage.total_tokens === "number" ? taskMsg.usage.total_tokens : undefined,
+ toolUses: typeof taskMsg.usage.tool_uses === "number" ? taskMsg.usage.tool_uses : undefined,
+ durationMs: typeof taskMsg.usage.duration_ms === "number" ? taskMsg.usage.duration_ms : undefined,
+ } : undefined,
+ lastToolName: typeof taskMsg.last_tool_name === "string" ? taskMsg.last_tool_name : undefined,
+ turnId,
+ });
+ continue;
+ }
+
// system:task_started — subagent spawn
if (msg.type === "system" && (msg as any).subtype === "task_started") {
const taskMsg = msg as any;
@@ -2615,6 +2696,7 @@ export function createAgentChatService(args: {
type: "subagent_started",
taskId,
description: String(taskMsg.description ?? ""),
+ background: isBackgroundTask(taskMsg as Record),
turnId,
});
continue;
@@ -2835,8 +2917,19 @@ export function createAgentChatService(args: {
continue;
}
- // prompt_suggestion — follow-up suggestions (consume silently for now)
+ // prompt_suggestion — follow-up suggestions forwarded to the UI
if ((msg as any).type === "prompt_suggestion") {
+ const suggestionMsg = msg as Record;
+ const suggestionText =
+ [suggestionMsg.suggestion, suggestionMsg.prompt, suggestionMsg.text]
+ .find((v): v is string => typeof v === "string" && v.trim().length > 0)?.trim() ?? null;
+ if (suggestionText) {
+ emitChatEvent(managed, {
+ type: "prompt_suggestion",
+ suggestion: suggestionText,
+ turnId,
+ });
+ }
continue;
}
}
@@ -2849,6 +2942,15 @@ export function createAgentChatService(args: {
managed.session.status = "idle";
reportProviderRuntimeReady("claude");
+ // Flush deferred session reset from mid-turn reasoning effort change
+ if (runtime.pendingSessionReset) {
+ runtime.pendingSessionReset = false;
+ cancelClaudeWarmup(managed, runtime, "session_reset");
+ try { runtime.v2Session?.close(); } catch { /* ignore */ }
+ runtime.v2Session = null;
+ runtime.v2WarmupDone = null;
+ }
+
const finalStatus = runtime.interrupted ? "interrupted" : "completed";
emitChatEvent(managed, { type: "status", turnStatus: finalStatus, turnId });
emitChatEvent(managed, {
@@ -3031,6 +3133,19 @@ export function createAgentChatService(args: {
let assistantText = "";
let usage: { inputTokens?: number | null; outputTokens?: number | null } | undefined;
let streamedStepCount = 0;
+ const turnStartedAt = Date.now();
+ let firstStreamEventLogged = false;
+ const markFirstStreamEvent = (kind: string): void => {
+ if (firstStreamEventLogged) return;
+ firstStreamEventLogged = true;
+ logger.info("agent_chat.turn_first_event", {
+ sessionId: managed.session.id,
+ provider: managed.session.provider,
+ turnId,
+ kind,
+ latencyMs: Date.now() - turnStartedAt,
+ });
+ };
try {
const lightweight = isLightweightSession(managed.session);
@@ -3168,6 +3283,9 @@ export function createAgentChatService(args: {
workerHeartbeatService: workerHeartbeatService ?? null,
linearDispatcherService: getLinearDispatcherService?.() ?? null,
flowPolicyService: flowPolicyService ?? null,
+ prService: prService ?? null,
+ fileService: fileService ?? null,
+ processService: processService ?? null,
issueTracker: linearIssueTracker ?? null,
listChats: listSessions,
getChatStatus: getSessionSummary,
@@ -3176,6 +3294,8 @@ export function createAgentChatService(args: {
updateChatSession: updateSession,
sendChatMessage: sendMessage,
interruptChat: interrupt,
+ resumeChat: resumeSession,
+ disposeChat: dispose,
ensureCtoSession: async ({ laneId, modelId, reasoningEffort, reuseExisting }) =>
ensureIdentitySession({
identityKey: "cto",
@@ -3185,13 +3305,6 @@ export function createAgentChatService(args: {
reuseExisting,
permissionMode: "full-auto",
}),
- fetchMissionContext: async (missionId) => {
- const result = await fetchContextPack({ scope: "mission", missionId, level: "standard" });
- return {
- content: result.content,
- truncated: result.truncated,
- };
- },
}));
}
}
@@ -3237,6 +3350,7 @@ export function createAgentChatService(args: {
const streamSupportsReasoning = runtime.modelDescriptor.capabilities.reasoning;
for await (const part of stream.fullStream as AsyncIterable) {
if (!part || typeof part !== "object") continue;
+ markFirstStreamEvent(String(part.type ?? "unknown"));
if (part.type === "start-step") {
streamedStepCount += 1;
@@ -3670,6 +3784,7 @@ export function createAgentChatService(args: {
type: "subagent_started",
taskId: itemId,
description: String(item.description ?? item.title ?? "Delegated task"),
+ background: isBackgroundTask(item as Record),
turnId,
});
}
@@ -3685,6 +3800,144 @@ export function createAgentChatService(args: {
return;
}
+ // collabToolCall items → subagent events (Codex parallel agents)
+ if (itemType === "collabToolCall") {
+ const tool = String(item.tool ?? "");
+ const prompt = typeof item.prompt === "string" ? item.prompt : "";
+ const agentsStates = Array.isArray(item.agentsStates) ? item.agentsStates : [];
+ const newThreadId = typeof item.newThreadId === "string" ? item.newThreadId : null;
+
+ if (tool === "spawn_agent" && eventKind === "started") {
+ emitChatEvent(managed, {
+ type: "activity",
+ activity: "spawning_agent",
+ detail: prompt.slice(0, 80) || "Spawning parallel agent",
+ turnId,
+ });
+ emitChatEvent(managed, {
+ type: "subagent_started",
+ taskId: newThreadId ?? itemId,
+ description: prompt.slice(0, 120) || "Parallel agent",
+ background: isBackgroundTask(item as Record),
+ turnId,
+ });
+ }
+
+ if ((tool === "send_input" || tool === "resume_agent") && eventKind === "completed") {
+ const receiverIds = Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : [];
+ const targetId = typeof receiverIds[0] === "string" ? receiverIds[0] : itemId;
+ emitChatEvent(managed, {
+ type: "subagent_progress",
+ taskId: targetId,
+ summary: prompt || "Agent received input",
+ turnId,
+ });
+ }
+
+ if (tool === "wait" && eventKind === "completed") {
+ for (const agentState of agentsStates) {
+ if (!agentState || typeof agentState !== "object") continue;
+ const state = agentState as Record;
+ const agentThreadId = typeof state.threadId === "string" ? state.threadId : itemId;
+ const summary = typeof state.summary === "string" ? state.summary
+ : typeof state.result === "string" ? state.result
+ : "";
+ const rawStatus = String(state.status ?? "completed");
+ const subagentStatus: "completed" | "failed" | "stopped" =
+ rawStatus === "failed" ? "failed"
+ : rawStatus === "stopped" ? "stopped"
+ : "completed";
+ emitChatEvent(managed, {
+ type: "subagent_result",
+ taskId: agentThreadId,
+ status: subagentStatus,
+ summary,
+ turnId,
+ });
+ }
+ }
+
+ if (tool === "close_agent" && eventKind === "completed") {
+ const receiverIds = Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : [];
+ const targetId = typeof receiverIds[0] === "string" ? receiverIds[0] : itemId;
+ emitChatEvent(managed, {
+ type: "subagent_result",
+ taskId: targetId,
+ status: "stopped",
+ summary: "Agent closed",
+ turnId,
+ });
+ }
+
+ return;
+ }
+
+ // dynamicToolCall items → tool_call/tool_result events
+ if (itemType === "dynamicToolCall") {
+ const toolName = String(item.tool ?? "dynamic_tool");
+ if (eventKind === "started") {
+ emitChatEvent(managed, {
+ type: "activity",
+ activity: "tool_calling",
+ detail: toolName,
+ turnId,
+ });
+ emitChatEvent(managed, {
+ type: "tool_call",
+ tool: toolName,
+ args: item.arguments,
+ itemId,
+ turnId,
+ });
+ }
+ if (eventKind === "completed") {
+ const success = item.success !== false;
+ const contentItems = Array.isArray(item.contentItems) ? item.contentItems : [];
+ const resultText = contentItems
+ .map((ci: unknown) => {
+ if (typeof ci === "string") return ci;
+ if (ci && typeof ci === "object" && typeof (ci as Record).text === "string") {
+ return (ci as Record).text as string;
+ }
+ return "";
+ })
+ .filter(Boolean)
+ .join("\n");
+ emitChatEvent(managed, {
+ type: "tool_result",
+ tool: toolName,
+ result: resultText || (success ? "Completed" : "Failed"),
+ itemId,
+ turnId,
+ status: success ? "completed" : "failed",
+ });
+ }
+ return;
+ }
+
+ // webSearch items → web_search events
+ if (itemType === "webSearch") {
+ emitChatEvent(managed, {
+ type: "activity",
+ activity: "web_searching",
+ detail: String(item.query ?? "Searching the web"),
+ turnId,
+ });
+ let status: "running" | "completed" | "failed" = "running";
+ if (eventKind === "completed") {
+ status = String(item.status ?? "completed") === "failed" ? "failed" : "completed";
+ }
+ emitChatEvent(managed, {
+ type: "web_search",
+ query: String(item.query ?? ""),
+ action: typeof item.action === "string" ? item.action : undefined,
+ itemId,
+ turnId,
+ status,
+ });
+ return;
+ }
+
// Planning items → todo_update
if (itemType === "planningItem" || itemType === "planning") {
const steps = Array.isArray(item.steps) ? item.steps : Array.isArray(item.plan) ? item.plan : [];
@@ -3961,6 +4214,54 @@ export function createAgentChatService(args: {
return;
}
+ if (method === "item/plan/delta") {
+ const delta = String((params.delta as string | undefined) ?? "");
+ if (!delta.length) return;
+ emitChatEvent(managed, {
+ type: "plan_text",
+ text: delta,
+ turnId: typeof params.turnId === "string" ? params.turnId : undefined,
+ itemId: typeof params.itemId === "string" ? params.itemId : undefined,
+ });
+ return;
+ }
+
+ if (method === "item/reasoning/summaryPartAdded") {
+ // Summary part boundary — no additional handling needed since we already
+ // merge reasoning deltas by turnId/itemId/summaryIndex.
+ return;
+ }
+
+ if (method === "item/autoApprovalReview/started") {
+ const targetItemId = String((params.targetItemId as string | undefined) ?? "");
+ if (targetItemId) {
+ emitChatEvent(managed, {
+ type: "auto_approval_review",
+ targetItemId,
+ reviewStatus: "started",
+ turnId: typeof params.turnId === "string" ? params.turnId : undefined,
+ });
+ }
+ return;
+ }
+
+ if (method === "item/autoApprovalReview/completed") {
+ const targetItemId = String((params.targetItemId as string | undefined) ?? "");
+ const action = typeof params.action === "string" ? params.action : undefined;
+ const review = typeof params.review === "string" ? params.review : undefined;
+ if (targetItemId) {
+ emitChatEvent(managed, {
+ type: "auto_approval_review",
+ targetItemId,
+ reviewStatus: "completed",
+ action,
+ review,
+ turnId: typeof params.turnId === "string" ? params.turnId : undefined,
+ });
+ }
+ return;
+ }
+
// Log unhandled notification methods for debugging
if (method) {
logger.warn("agent_chat.codex_unhandled_notification", {
@@ -4217,6 +4518,8 @@ export function createAgentChatService(args: {
cwd: managed.laneWorktreePath,
permissionMode: claudePermissionMode as any,
includePartialMessages: true,
+ agentProgressSummaries: true,
+ promptSuggestions: true,
maxBudgetUsd: chatConfig.sessionBudgetUsd ?? undefined,
model: resolveClaudeCliModel(managed.session.model),
};
@@ -4241,6 +4544,14 @@ export function createAgentChatService(args: {
managed.session.computerUse,
) as any;
if (canUseTool) opts.canUseTool = canUseTool as any;
+
+ // Enable MCP tool search for sessions with many MCP tools.
+ // When enabled, the SDK defers tool definitions and loads them on-demand
+ // via the ToolSearch tool, keeping the context window lean.
+ opts.env = {
+ ...opts.env as Record | undefined,
+ ENABLE_TOOL_SEARCH: "auto",
+ };
}
const claudeDescriptor = resolveSessionModelDescriptor(managed.session);
const claudeSupportsReasoning = claudeDescriptor?.capabilities.reasoning ?? true;
@@ -4264,11 +4575,49 @@ export function createAgentChatService(args: {
return { ...opts, model };
};
+ const cancelClaudeWarmup = (
+ managed: ManagedChatSession,
+ runtime: ClaudeRuntime,
+ reason: "interrupt" | "teardown" | "session_reset",
+ ): void => {
+ if (!runtime.v2WarmupDone) return;
+ runtime.v2WarmupCancelled = true;
+ runtime.v2WarmupCancel?.();
+ logger.info("agent_chat.claude_v2_prewarm_cancel", {
+ sessionId: managed.session.id,
+ reason,
+ });
+ };
+
+ const applyClaudeSlashCommands = (
+ runtime: ClaudeRuntime,
+ commands: Array,
+ ): void => {
+ runtime.slashCommands = commands
+ .map((command) => {
+ if (typeof command === "string") {
+ const normalized = command.trim();
+ if (!normalized.length) return null;
+ return {
+ name: normalized.startsWith("/") ? normalized : `/${normalized}`,
+ description: "",
+ };
+ }
+ const normalized = typeof command.name === "string" ? command.name.trim() : "";
+ if (!normalized.length) return null;
+ return {
+ name: normalized.startsWith("/") ? normalized : `/${normalized}`,
+ description: typeof command.description === "string" ? command.description : "",
+ argumentHint: typeof command.argumentHint === "string" ? command.argumentHint : undefined,
+ };
+ })
+ .filter((command): command is { name: string; description: string; argumentHint?: string } => Boolean(command));
+ };
+
/**
* Pre-warm the Claude V2 session in the background.
- * Creates the session and sends a warmup turn so the subprocess + MCP servers
- * are fully initialized by the time the user sends their first real message.
- * The warmup turn (~30s cold start) runs while the user is composing their message.
+ * Creates the persistent session and runs a silent warmup turn because the
+ * public V2 session API does not expose an init-only readiness handshake.
*/
const prewarmClaudeV2Session = (managed: ManagedChatSession): void => {
const runtime = managed.runtime;
@@ -4276,8 +4625,18 @@ export function createAgentChatService(args: {
if (runtime.v2Session || runtime.v2WarmupDone) return;
runtime.v2WarmupCancelled = false;
+ const warmupStartedAt = Date.now();
+ let settleWarmupWaiters: (() => void) | null = null;
+ const waitForCancel = new Promise((resolve) => {
+ settleWarmupWaiters = resolve;
+ });
+ const cancelWarmup = () => {
+ settleWarmupWaiters?.();
+ settleWarmupWaiters = null;
+ };
+ runtime.v2WarmupCancel = cancelWarmup;
- runtime.v2WarmupDone = (async () => {
+ const warmupTask = (async () => {
try {
const v2Opts = buildClaudeV2SessionOpts(managed, runtime);
logger.info("agent_chat.claude_v2_prewarm_start", {
@@ -4300,9 +4659,6 @@ export function createAgentChatService(args: {
return;
}
- // Send a warmup turn — this triggers the ~30s subprocess cold start.
- // The response is consumed silently. By the time the user types and sends
- // their first real message, the subprocess is already running.
await runtime.v2Session.send("System initialization check. Respond with only the word READY.");
for await (const msg of runtime.v2Session.stream()) {
if (runtime.v2WarmupCancelled) break;
@@ -4313,20 +4669,14 @@ export function createAgentChatService(args: {
const initMsg = msg as any;
runtime.sdkSessionId = initMsg.session_id ?? runtime.sdkSessionId;
if (Array.isArray(initMsg.slash_commands)) {
- runtime.slashCommands = initMsg.slash_commands
- .filter((cmd: unknown) => typeof cmd === "string" && cmd.length > 0)
- .map((cmd: string) => ({ name: cmd.startsWith("/") ? cmd : `/${cmd}`, description: "" }));
+ applyClaudeSlashCommands(runtime, initMsg.slash_commands);
}
try {
const sessionImpl = runtime.v2Session as any;
if (typeof sessionImpl?.supportedCommands === "function") {
sessionImpl.supportedCommands().then((cmds: any[]) => {
if (Array.isArray(cmds) && cmds.length > 0) {
- runtime.slashCommands = cmds.map((c: any) => ({
- name: typeof c.name === "string" ? (c.name.startsWith("/") ? c.name : `/${c.name}`) : String(c),
- description: typeof c.description === "string" ? c.description : "",
- argumentHint: typeof c.argumentHint === "string" ? c.argumentHint : undefined,
- }));
+ applyClaudeSlashCommands(runtime, cmds);
}
}).catch(() => { /* not available */ });
}
@@ -4375,6 +4725,23 @@ export function createAgentChatService(args: {
runtime.v2Session = null;
}
})();
+
+ const warmupPromise = Promise.race([warmupTask, waitForCancel]);
+ runtime.v2WarmupDone = warmupPromise;
+
+ void warmupPromise.finally(() => {
+ if (runtime.v2WarmupDone === warmupPromise) {
+ runtime.v2WarmupDone = null;
+ }
+ if (runtime.v2WarmupCancel === cancelWarmup) {
+ runtime.v2WarmupCancel = null;
+ }
+ logger.info("agent_chat.claude_v2_prewarm_settled", {
+ sessionId: managed.session.id,
+ cancelled: runtime.v2WarmupCancelled,
+ durationMs: Date.now() - warmupStartedAt,
+ });
+ });
};
const ensureClaudeSessionRuntime = (managed: ManagedChatSession): ClaudeRuntime => {
@@ -4389,6 +4756,7 @@ export function createAgentChatService(args: {
v2Session: null,
v2StreamGen: null,
v2WarmupDone: null,
+ v2WarmupCancel: null,
v2WarmupCancelled: false,
activeSubagents: new Map(),
slashCommands: [],
@@ -4721,16 +5089,16 @@ export function createAgentChatService(args: {
return managed.session;
};
- const sendMessage = async ({
+ const prepareSendMessage = ({
sessionId,
text,
displayText,
attachments = [],
reasoningEffort,
executionMode,
- }: AgentChatSendArgs): Promise => {
+ }: AgentChatSendArgs): PreparedSendMessage | null => {
const trimmed = text.trim();
- if (!trimmed.length) return;
+ if (!trimmed.length) return null;
const slashCommand = extractSlashCommand(trimmed);
const visibleText = displayText?.trim().length ? displayText.trim() : trimmed;
@@ -4775,6 +5143,75 @@ export function createAgentChatService(args: {
managed.session.executionMode = "focused";
}
+ return {
+ sessionId,
+ managed,
+ promptText,
+ visibleText,
+ attachments,
+ reasoningEffort,
+ };
+ };
+
+ const emitDispatchedSendFailure = (prepared: PreparedSendMessage, error: unknown): void => {
+ const { managed } = prepared;
+ if (managed.closed) return;
+
+ const message = error instanceof Error ? error.message : String(error);
+ const turnId = randomUUID();
+ managed.session.status = "idle";
+
+ if (managed.runtime?.kind === "codex") {
+ managed.runtime.activeTurnId = null;
+ managed.runtime.startedTurnId = null;
+ }
+ if (managed.runtime?.kind === "unified") {
+ managed.runtime.busy = false;
+ managed.runtime.activeTurnId = null;
+ managed.runtime.abortController = null;
+ }
+ if (managed.runtime?.kind === "claude") {
+ managed.runtime.busy = false;
+ managed.runtime.activeTurnId = null;
+ managed.runtime.activeQuery = null;
+ }
+
+ emitChatEvent(managed, {
+ type: "error",
+ message,
+ turnId,
+ });
+ emitChatEvent(managed, {
+ type: "status",
+ turnStatus: "failed",
+ message,
+ turnId,
+ });
+ emitChatEvent(managed, {
+ type: "done",
+ turnId,
+ status: "failed",
+ model: managed.session.model,
+ ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}),
+ });
+
+ appendWorkerActivityToCto(managed, {
+ activityType: "chat_turn",
+ summary: `Turn failed before execution: ${message}`,
+ });
+ persistChatState(managed);
+ };
+
+ const executePreparedSendMessage = async (prepared: PreparedSendMessage): Promise => {
+ const {
+ sessionId,
+ managed,
+ promptText,
+ visibleText,
+ attachments,
+ reasoningEffort,
+ } = prepared;
+
// Unified runtime dispatch
if (managed.session.provider === "unified") {
if (!managed.runtime || managed.runtime.kind !== "unified") {
@@ -4866,6 +5303,28 @@ export function createAgentChatService(args: {
await runClaudeTurn(managed, { promptText, displayText: visibleText, attachments });
};
+ const sendMessage = async (args: AgentChatSendArgs): Promise => {
+ const dispatchStartedAt = Date.now();
+ const prepared = prepareSendMessage(args);
+ if (!prepared) return;
+
+ logger.info("agent_chat.turn_dispatch_ack", {
+ sessionId: prepared.sessionId,
+ provider: prepared.managed.session.provider,
+ model: prepared.managed.session.model,
+ durationMs: Date.now() - dispatchStartedAt,
+ });
+
+ void executePreparedSendMessage(prepared).catch((error) => {
+ logger.warn("agent_chat.turn_dispatch_failed", {
+ sessionId: prepared.sessionId,
+ provider: prepared.managed.session.provider,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ emitDispatchedSendFailure(prepared, error);
+ });
+ };
+
const steer = async ({ sessionId, text }: AgentChatSteerArgs): Promise => {
const trimmed = text.trim();
if (!trimmed.length) return;
@@ -4951,13 +5410,27 @@ export function createAgentChatService(args: {
}
const runtime = ensureClaudeSessionRuntime(managed);
+ logger.info("agent_chat.turn_interrupt_requested", {
+ sessionId,
+ provider: "claude",
+ turnId: runtime.activeTurnId,
+ busy: runtime.busy,
+ warmupInFlight: Boolean(runtime.v2WarmupDone),
+ });
runtime.interrupted = true;
+ cancelClaudeWarmup(managed, runtime, "interrupt");
runtime.activeQuery?.interrupt().catch(() => {});
// Close the V2 session on interrupt — it will be recreated on the next turn
try { runtime.v2Session?.close(); } catch { /* ignore */ }
runtime.v2Session = null;
runtime.v2StreamGen = null;
- runtime.v2WarmupDone = null;
+ runtime.activeSubagents.clear();
+ logger.info("agent_chat.turn_interrupt_completed", {
+ sessionId,
+ provider: "claude",
+ turnId: runtime.activeTurnId,
+ busy: runtime.busy,
+ });
};
const resumeSession = async ({ sessionId }: { sessionId: string }): Promise => {
@@ -5452,6 +5925,18 @@ export function createAgentChatService(args: {
ensureClaudeSessionRuntime(managed);
prewarmClaudeV2Session(managed);
}
+
+ // If V2 session is alive and model changed, notify SDK
+ if (managed.runtime?.kind === "claude" && managed.runtime.v2Session && modelId) {
+ const newCliModel = resolveClaudeCliModel(managed.session.model);
+ if (newCliModel && typeof (managed.runtime.v2Session as any).setModel === "function") {
+ try {
+ (managed.runtime.v2Session as any).setModel(newCliModel);
+ } catch (err) {
+ logger.warn("agent_chat.v2_set_model_failed", { sessionId: managed.session.id, error: String(err) });
+ }
+ }
+ }
} else if (reasoningEffort !== undefined) {
const prev = managed.session.reasoningEffort ?? null;
managed.session.reasoningEffort = normalizeReasoningEffort(reasoningEffort);
@@ -5459,10 +5944,17 @@ export function createAgentChatService(args: {
// When reasoning effort changes on a Claude session with an active V2
// session, invalidate the V2 session so it is recreated on the next turn
// with the updated thinking configuration.
- if (prev !== next && managed.runtime?.kind === "claude" && managed.runtime.v2Session) {
- managed.runtime.v2Session.close();
- managed.runtime.v2Session = null;
- managed.runtime.v2WarmupDone = null;
+ if (prev !== next && managed.runtime?.kind === "claude" && (managed.runtime.v2Session || managed.runtime.v2WarmupDone)) {
+ if (managed.runtime.busy) {
+ // Defer session reset until the current turn completes — tearing down
+ // a live session mid-turn would force the stream down the failure path.
+ managed.runtime.pendingSessionReset = true;
+ } else {
+ cancelClaudeWarmup(managed, managed.runtime, "session_reset");
+ try { managed.runtime.v2Session?.close(); } catch { /* ignore */ }
+ managed.runtime.v2Session = null;
+ managed.runtime.v2WarmupDone = null;
+ }
}
}
@@ -5514,7 +6006,7 @@ export function createAgentChatService(args: {
if (!isAnthropicCli) return;
// Only prewarm if the session is idle (not mid-turn) and not already warmed
- if (managed.runtime?.kind === "claude" && managed.runtime.v2WarmupDone) return;
+ if (managed.runtime?.kind === "claude" && (managed.runtime.v2Session || managed.runtime.v2WarmupDone)) return;
// Apply the selected model to the session so buildClaudeV2SessionOpts
// picks up the correct model for warmup.
@@ -5527,78 +6019,6 @@ export function createAgentChatService(args: {
prewarmClaudeV2Session(managed);
};
- const listContextPacks = async (args: { laneId?: string } = {}): Promise => {
- const packs: import("../../../shared/types").ContextPackOption[] = [
- { scope: "project", label: "Project", description: "Live project context export", available: Boolean(packService) },
- ];
-
- if (args.laneId) {
- packs.push(
- { scope: "lane", label: "Lane", description: "Live lane context export", available: Boolean(packService), laneId: args.laneId },
- { scope: "conflict", label: "Conflicts", description: "Live conflict context export", available: Boolean(packService), laneId: args.laneId },
- { scope: "plan", label: "Plan", description: "Live plan context export", available: Boolean(packService), laneId: args.laneId }
- );
- }
-
- packs.push(
- {
- scope: "mission",
- label: "Mission",
- description: "Mission-scoped export requires an explicit mission selection and is not wired into this picker yet",
- available: false
- }
- );
-
- return packs;
- };
-
- const fetchContextPack = async (args: import("../../../shared/types").ContextPackFetchArgs): Promise => {
- const MAX_CHARS = 50_000;
- let content = "";
- let truncated = false;
- const level = args.level === "brief" ? "lite" : args.level === "detailed" ? "deep" : "standard";
-
- try {
- if (!packService) {
- throw new Error("Live context export service is unavailable.");
- }
-
- const exportResult = await (async () => {
- if (args.scope === "project") return await packService.getProjectExport({ level });
- if (args.scope === "lane") {
- if (!args.laneId?.trim()) throw new Error("Lane context requires laneId.");
- return await packService.getLaneExport({ laneId: args.laneId.trim(), level });
- }
- if (args.scope === "conflict") {
- if (!args.laneId?.trim()) throw new Error("Conflict context requires laneId.");
- return await packService.getConflictExport({ laneId: args.laneId.trim(), level });
- }
- if (args.scope === "plan") {
- if (!args.laneId?.trim()) throw new Error("Plan context requires laneId.");
- return await packService.getPlanExport({ laneId: args.laneId.trim(), level });
- }
- if (args.scope === "feature") {
- if (!args.featureKey?.trim()) throw new Error("Feature context requires featureKey.");
- return await packService.getFeatureExport({ featureKey: args.featureKey.trim(), level });
- }
- if (!args.missionId?.trim()) throw new Error("Mission context requires missionId.");
- return await packService.getMissionExport({ missionId: args.missionId.trim(), level });
- })();
-
- content = exportResult.content;
- truncated = exportResult.truncated;
-
- if (content.length > MAX_CHARS) {
- content = content.slice(0, MAX_CHARS);
- truncated = true;
- }
- } catch (error) {
- content = `Failed to fetch ${args.scope} context: ${error instanceof Error ? error.message : String(error)}`;
- }
-
- return { scope: args.scope, content, truncated };
- };
-
const listSubagents = ({ sessionId }: AgentChatSubagentListArgs): AgentChatSubagentSnapshot[] => {
return getTrackedSubagents(sessionId);
};
@@ -5721,9 +6141,41 @@ export function createAgentChatService(args: {
threadId?: string;
sdkSessionId?: string | null;
}> => {
+ const managed = ensureManagedSession(sessionId);
+ const trimmed = text.trim();
+ if (!trimmed.length) {
+ return {
+ sessionId,
+ provider: managed.session.provider,
+ model: managed.session.model,
+ ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}),
+ outputText: "",
+ ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}),
+ ...(managed.runtime?.kind === "claude" ? { sdkSessionId: managed.runtime.sdkSessionId ?? null } : {}),
+ };
+ }
if (sessionTurnCollectors.has(sessionId)) {
throw new Error(`Session '${sessionId}' already has an active background turn.`);
}
+ const prepared = prepareSendMessage({
+ sessionId,
+ text,
+ displayText,
+ attachments,
+ reasoningEffort,
+ executionMode,
+ });
+ if (!prepared) {
+ return {
+ sessionId,
+ provider: managed.session.provider,
+ model: managed.session.model,
+ ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}),
+ outputText: "",
+ ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}),
+ ...(managed.runtime?.kind === "claude" ? { sdkSessionId: managed.runtime.sdkSessionId ?? null } : {}),
+ };
+ }
const safeTimeoutMs = Math.max(15_000, Math.floor(timeoutMs));
return await new Promise((resolve, reject) => {
@@ -5740,14 +6192,7 @@ export function createAgentChatService(args: {
timeout,
});
- void sendMessage({
- sessionId,
- text,
- displayText,
- attachments,
- reasoningEffort,
- executionMode,
- }).catch((error) => {
+ void executePreparedSendMessage(prepared).catch((error) => {
clearTimeout(timeout);
sessionTurnCollectors.delete(sessionId);
reject(error instanceof Error ? error : new Error(String(error)));
@@ -5774,8 +6219,6 @@ export function createAgentChatService(args: {
disposeAll,
updateSession,
warmupModel,
- listContextPacks,
- fetchContextPack,
changePermissionMode,
listSubagents,
getSessionCapabilities,
diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts
index c35d37e5..20ce4a4e 100644
--- a/apps/desktop/src/main/services/config/projectConfigService.ts
+++ b/apps/desktop/src/main/services/config/projectConfigService.ts
@@ -1193,10 +1193,7 @@ function coerceAiConfig(value: unknown): AiConfig | undefined {
defaultExecutionPolicy as NonNullable["defaultExecutionPolicy"]>;
}
- const defaultPlannerProvider = asString(orchestratorRaw.defaultPlannerProvider)?.trim();
- if (defaultPlannerProvider === "auto" || defaultPlannerProvider === "claude" || defaultPlannerProvider === "codex") {
- orchestrator.defaultPlannerProvider = defaultPlannerProvider;
- }
+ // Legacy defaultPlannerProvider is ignored -- use defaultOrchestratorModel instead.
const autoResolveInterventions = asBool(orchestratorRaw.autoResolveInterventions);
if (autoResolveInterventions != null) orchestrator.autoResolveInterventions = autoResolveInterventions;
@@ -2477,7 +2474,6 @@ function validateEffectiveConfig(
const ap = `${p}.legacy.actions[${actionIdx}]`;
const type = action.type as AutomationActionType;
if (
- type !== "update-packs" &&
type !== "predict-conflicts" &&
type !== "run-tests" &&
type !== "run-command"
diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts
index 3a7f8776..ea44b8ea 100644
--- a/apps/desktop/src/main/services/conflicts/conflictService.ts
+++ b/apps/desktop/src/main/services/conflicts/conflictService.ts
@@ -73,7 +73,7 @@ import type { createSessionService } from "../sessions/sessionService";
import { normalizeConflictType, runGit, runGitMergeTree, runGitOrThrow } from "../git/git";
import { redactSecretsDeep } from "../../utils/redaction";
import { extractFirstJsonObject } from "../ai/utils";
-import { safeSegment } from "../packs/packUtils";
+import { safeSegment } from "../shared/packLegacyUtils";
import { asString, isRecord, parseDiffNameOnly, safeJsonParse, uniqueSorted } from "../shared/utils";
type PredictionStatus = "clean" | "conflict" | "unknown";
diff --git a/apps/desktop/src/main/services/packs/projectPackBuilder.ts b/apps/desktop/src/main/services/context/contextDocBuilder.ts
similarity index 99%
rename from apps/desktop/src/main/services/packs/projectPackBuilder.ts
rename to apps/desktop/src/main/services/context/contextDocBuilder.ts
index 6064d0f7..3bbbbcdf 100644
--- a/apps/desktop/src/main/services/packs/projectPackBuilder.ts
+++ b/apps/desktop/src/main/services/context/contextDocBuilder.ts
@@ -28,7 +28,7 @@ import {
isRecord,
asString,
readFileIfExists
-} from "./packUtils";
+} from "../shared/packLegacyUtils";
// ── Constants ────────────────────────────────────────────────────────────────
diff --git a/apps/desktop/src/main/services/context/contextDocService.test.ts b/apps/desktop/src/main/services/context/contextDocService.test.ts
index b5a875b1..9bd020d3 100644
--- a/apps/desktop/src/main/services/context/contextDocService.test.ts
+++ b/apps/desktop/src/main/services/context/contextDocService.test.ts
@@ -4,7 +4,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { openKvDb } from "../state/kvDb";
-vi.mock("../packs/projectPackBuilder", () => ({
+vi.mock("./contextDocBuilder", () => ({
readContextDocMeta: vi.fn(() => ({
contextFingerprint: "fingerprint",
contextVersion: 1,
@@ -44,7 +44,7 @@ import { createContextDocService } from "./contextDocService";
import {
resolveContextDocPath,
runContextDocGeneration,
-} from "../packs/projectPackBuilder";
+} from "./contextDocBuilder";
function createLogger() {
return {
diff --git a/apps/desktop/src/main/services/context/contextDocService.ts b/apps/desktop/src/main/services/context/contextDocService.ts
index 8407a8be..21696140 100644
--- a/apps/desktop/src/main/services/context/contextDocService.ts
+++ b/apps/desktop/src/main/services/context/contextDocService.ts
@@ -8,7 +8,7 @@ import {
readContextStatus as readContextStatusImpl,
resolveContextDocPath as resolveContextDocPathImpl,
runContextDocGeneration as runContextDocGenerationImpl,
-} from "../packs/projectPackBuilder";
+} from "./contextDocBuilder";
import { getErrorMessage, nowIso, toOptionalString } from "../shared/utils";
import type { AdeDb } from "../state/kvDb";
import type {
diff --git a/apps/desktop/src/main/services/cto/ctoStateService.test.ts b/apps/desktop/src/main/services/cto/ctoStateService.test.ts
index 87c9392f..3792a2fc 100644
--- a/apps/desktop/src/main/services/cto/ctoStateService.test.ts
+++ b/apps/desktop/src/main/services/cto/ctoStateService.test.ts
@@ -521,6 +521,7 @@ describe("ctoStateService", () => {
});
expect(reloaded.getOnboardingState().completedSteps).toEqual(["identity"]);
+ expect(reloaded.getOnboardingState().completedAt).toBeTruthy();
expect(reloaded.getIdentity().personality).toBe("casual");
expect(reloaded.getIdentity().constraints).toEqual(["no force push", "write tests"]);
expect(reloaded.getIdentity().systemPromptExtension).toBe("Stay calm under pressure.");
@@ -542,12 +543,17 @@ describe("ctoStateService", () => {
});
const preview = service.previewSystemPrompt();
- expect(preview.sections.map((section) => section.id)).toEqual(["doctrine", "personality", "memory"]);
+ expect(preview.sections.map((section) => section.id)).toEqual(["doctrine", "personality", "memory", "capabilities"]);
expect(preview.sections[0]?.content).toContain("You are the CTO for the current project inside ADE.");
expect(preview.sections[1]?.content).toContain("Operate as a strategic CTO.");
expect(preview.sections[2]?.content).toContain("Immutable doctrine");
+ expect(preview.sections[2]?.content).toContain("Use memoryUpdateCore only when the standing project brief changes");
+ expect(preview.sections[2]?.content).toContain("Do not write ephemeral turn-by-turn status");
+ expect(preview.sections[3]?.content).toContain("ADE operator capability manifest");
+ expect(preview.sections[3]?.content).toContain("UI navigation is suggestion-only.");
expect(preview.prompt).toContain("Immutable ADE doctrine");
expect(preview.prompt).toContain("Selected personality overlay");
+ expect(preview.prompt).toContain("ADE operator capability manifest");
fixture.db.close();
});
diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts
index 712dd458..84715c74 100644
--- a/apps/desktop/src/main/services/cto/ctoStateService.ts
+++ b/apps/desktop/src/main/services/cto/ctoStateService.ts
@@ -55,6 +55,7 @@ type PersistedDoc = {
const CTO_LONG_TERM_MEMORY_RELATIVE_PATH = ".ade/cto/MEMORY.md";
const CTO_CURRENT_CONTEXT_RELATIVE_PATH = ".ade/cto/CURRENT.md";
+const CTO_REQUIRED_ONBOARDING_STEPS = ["identity"] as const;
const DURABLE_MEMORY_CATEGORY_ORDER: MemoryCategory[] = [
"decision",
"convention",
@@ -81,16 +82,34 @@ const IMMUTABLE_CTO_DOCTRINE = [
const CTO_MEMORY_OPERATING_MODEL = [
"ADE continuity model:",
"1. Immutable doctrine: ADE always re-applies this CTO doctrine. It is not user-editable and it is not compacted away.",
- `2. Long-term CTO brief: ${CTO_LONG_TERM_MEMORY_RELATIVE_PATH}. This stores project summary, conventions, preferences, focus, and standing notes.`,
- `3. Current working context: ${CTO_CURRENT_CONTEXT_RELATIVE_PATH}. This carries recent sessions, worker activity, and active carry-forward context.`,
+ `2. Long-term CTO brief: ${CTO_LONG_TERM_MEMORY_RELATIVE_PATH}. ADE maintains this project-level state for summary, conventions, preferences, focus, and standing notes.`,
+ `3. Current working context: ${CTO_CURRENT_CONTEXT_RELATIVE_PATH}. ADE maintains this project-level state for recent sessions, worker activity, and active carry-forward context.`,
"4. Durable searchable project memory. Use memorySearch to retrieve reusable context and memoryAdd to store stable lessons, decisions, patterns, gotchas, and preferences.",
"",
"Compaction and recovery rules:",
"- Treat memory as mandatory operating infrastructure, not optional notes.",
- "- Before non-trivial work, re-ground yourself in the long-term brief, current context, and durable memory.",
- "- When the project brief changes, update Layer 2 with memoryUpdateCore.",
- "- When you learn something reusable, store it with memoryAdd immediately.",
- "- Distill important session context before compaction removes detail.",
+ "- Before non-trivial work, before asking the user to restate context, and before entering an unfamiliar subsystem, re-ground yourself in the long-term brief, current context, and durable memory.",
+ "- ADE already injects reconstructed CTO state into the session. Do not spend turns shell-reading relative .ade/cto files from the workspace unless an explicit absolute file path is required.",
+ "- Use memoryUpdateCore only when the standing project brief changes: summary, conventions, preferences, active focus, or standing notes.",
+ "- Use memoryAdd for reusable decisions, patterns, gotchas, and stable preferences that are likely to matter again.",
+ "- Do not write ephemeral turn-by-turn status, scratch notes, or one-off observations that can be recovered from the repo or recent chat history.",
+ "- Distill important session context before compaction removes detail, but persist only durable insights.",
+].join("\n");
+
+const CTO_CAPABILITY_MANIFEST = [
+ "ADE operator capability manifest:",
+ "- Work chats: create, list, inspect, read transcripts, send follow-ups, interrupt, and end supervised sessions through ADE services.",
+ "- Lanes: list, inspect, create, and archive lanes, then hand back explicit navigation suggestions for opening them in ADE.",
+ "- Missions: create, inspect, launch, steer, read logs, export context, and route work into mission execution without exposing raw coordinator internals.",
+ "- Run / processes: inspect managed lane processes, start them, stop them, and read bounded log tails through ADE's process service.",
+ "- Pull requests and GitHub: inspect PR state, create PRs from lanes, update PR metadata, and post review-facing comments through stable PR services.",
+ "- Files and context: enumerate workspaces, read files, search text, and export bounded ADE context packs for project, lane, mission, conflict, plan, and feature scopes.",
+ "- Workers and Linear: supervise worker agents, trigger wakeups, inspect workflow runs, and route Linear issues into CTO, mission, or worker paths.",
+ "",
+ "Operating rules:",
+ "- Internal ADE actions run through service-backed tools even when no renderer click occurs.",
+ "- UI navigation is suggestion-only. When an action should be opened in ADE, return an explicit navigation suggestion instead of silently switching tabs.",
+ "- Treat ADE as your operating environment. Do not describe yourself as blocked on renderer button clicks when an internal tool can do the work.",
].join("\n");
function asStringArray(value: unknown): string[] {
@@ -142,6 +161,11 @@ function normalizePersonalityPreset(value: unknown): CtoIdentity["personality"]
: undefined;
}
+function hasCompletedRequiredOnboardingSteps(state: CtoOnboardingState | null | undefined): boolean {
+ const completedSteps = state?.completedSteps ?? [];
+ return CTO_REQUIRED_ONBOARDING_STEPS.every((stepId) => completedSteps.includes(stepId));
+}
+
function resolvePersonalityOverlay(identity: CtoIdentity): string {
const presetId = identity.personality ?? "strategic";
if (presetId === "custom") {
@@ -947,10 +971,12 @@ export function createCtoStateService(args: CtoStateServiceArgs) {
const snapshot = getSnapshot(recentLimit);
const sections: string[] = [];
sections.push("CTO Memory Stack");
+ sections.push("The CTO state below is already reconstructed by ADE for this session. Do not burn turns trying to rediscover it by shelling into relative .ade/cto paths.");
sections.push(`- Layer 1 — runtime identity and operating doctrine. Hidden system instructions and identity.yaml keep you in the CTO role.`);
sections.push(`- Layer 2 — long-term CTO brief at ${CTO_LONG_TERM_MEMORY_RELATIVE_PATH}. Update this layer with memoryUpdateCore when the project summary, conventions, preferences, focus, or standing notes change.`);
sections.push(`- Layer 3 — current working context at ${CTO_CURRENT_CONTEXT_RELATIVE_PATH}. This layer carries active focus, recent sessions, worker activity, and daily logs through compaction.`);
sections.push("- Layer 4 — searchable durable project memory. Use memorySearch before non-trivial work and memoryAdd for reusable decisions, conventions, patterns, gotchas, and stable preferences.");
+ sections.push("- Memory write policy: use memoryUpdateCore for standing brief changes, use memoryAdd for durable reusable lessons, and skip ephemeral status notes.");
sections.push("");
sections.push("CTO Identity");
sections.push(`- Name: ${snapshot.identity.name}`);
@@ -973,20 +999,8 @@ export function createCtoStateService(args: CtoStateServiceArgs) {
return identity.onboardingState ?? { completedSteps: [] };
};
- const completeOnboardingStep = (stepId: string): CtoOnboardingState => {
+ const persistOnboardingState = (next: CtoOnboardingState): CtoOnboardingState => {
const identity = getIdentity();
- const current = identity.onboardingState ?? { completedSteps: [] };
- if (current.completedSteps.includes(stepId)) return current;
-
- const next: CtoOnboardingState = {
- ...current,
- completedSteps: [...current.completedSteps, stepId],
- };
- // If all 3 steps complete, mark completed
- if (next.completedSteps.length >= 3 && !next.completedAt) {
- next.completedAt = nowIso();
- }
-
const updated: CtoIdentity = {
...identity,
onboardingState: next,
@@ -999,35 +1013,33 @@ export function createCtoStateService(args: CtoStateServiceArgs) {
return next;
};
+ const maybeMarkOnboardingComplete = (state: CtoOnboardingState): CtoOnboardingState => {
+ if (hasCompletedRequiredOnboardingSteps(state) && !state.completedAt) {
+ return { ...state, completedAt: nowIso() };
+ }
+ return state;
+ };
+
+ const completeOnboardingStep = (stepId: string): CtoOnboardingState => {
+ const current = getOnboardingState();
+ if (current.completedSteps.includes(stepId)) {
+ const patched = maybeMarkOnboardingComplete(current);
+ if (patched !== current) return persistOnboardingState(patched);
+ return current;
+ }
+ const next = maybeMarkOnboardingComplete({
+ ...current,
+ completedSteps: [...current.completedSteps, stepId],
+ });
+ return persistOnboardingState(next);
+ };
+
const dismissOnboarding = (): CtoOnboardingState => {
- const identity = getIdentity();
- const current = identity.onboardingState ?? { completedSteps: [] };
- const next: CtoOnboardingState = { ...current, dismissedAt: nowIso() };
- const updated: CtoIdentity = {
- ...identity,
- onboardingState: next,
- version: identity.version + 1,
- updatedAt: nowIso(),
- };
- writeIdentityToFile(updated);
- writeIdentityToDb(updated);
- syncDerivedMemoryDocs();
- return next;
+ return persistOnboardingState({ ...getOnboardingState(), dismissedAt: nowIso() });
};
const resetOnboarding = (): CtoOnboardingState => {
- const identity = getIdentity();
- const next: CtoOnboardingState = { completedSteps: [] };
- const updated: CtoIdentity = {
- ...identity,
- onboardingState: next,
- version: identity.version + 1,
- updatedAt: nowIso(),
- };
- writeIdentityToFile(updated);
- writeIdentityToDb(updated);
- syncDerivedMemoryDocs();
- return next;
+ return persistOnboardingState({ completedSteps: [] });
};
/* ── Identity update (full patch) ── */
@@ -1075,6 +1087,11 @@ export function createCtoStateService(args: CtoStateServiceArgs) {
title: "Memory and continuity model",
content: CTO_MEMORY_OPERATING_MODEL,
},
+ {
+ id: "capabilities",
+ title: "ADE operator capability manifest",
+ content: CTO_CAPABILITY_MANIFEST,
+ },
];
const prompt = [
diff --git a/apps/desktop/src/main/services/cto/workerAgentService.ts b/apps/desktop/src/main/services/cto/workerAgentService.ts
index fc66ce05..f509031f 100644
--- a/apps/desktop/src/main/services/cto/workerAgentService.ts
+++ b/apps/desktop/src/main/services/cto/workerAgentService.ts
@@ -335,9 +335,6 @@ function normalizeAdapterConfig(adapterType: AdapterType, config: Record;
missionBudgetService: ReturnType;
aiOrchestratorService: ReturnType;
- packService: ReturnType;
contextDocService?: ContextDocService | null;
projectConfigService: ReturnType;
processService: ReturnType;
@@ -735,22 +729,6 @@ function getUnavailableAiStatus(): AiSettingsStatus {
}
-async function safeRefreshMissionPack(
- ctx: AppContext,
- missionId: string,
- reason: string,
-): Promise {
- try {
- await ctx.packService.refreshMissionPack({ missionId, reason });
- } catch (error) {
- ctx.logger.warn("packs.refresh_mission_pack_failed", {
- missionId,
- reason,
- error: getErrorMessage(error)
- });
- }
-}
-
function normalizeAutopilotExecutor(value: unknown): OrchestratorExecutorKind {
const raw = typeof value === "string" ? value.trim() : "";
if (raw === "shell" || raw === "manual" || raw === "unified") return raw;
@@ -2405,8 +2383,6 @@ export function registerIpc({
autopilotExecutor: defaultExecutorKind
});
- await safeRefreshMissionPack(ctx, created.id, "mission_created");
-
void (async () => {
try {
triggerAutoContextDocs(ctx, {
@@ -2468,8 +2444,6 @@ export function registerIpc({
launchMode: "manual",
autostart: false,
});
- await safeRefreshMissionPack(ctx, created.id, "mission_created");
-
const detail = ctx.missionService.get(created.id);
if (detail) return detail;
return created;
@@ -2478,7 +2452,6 @@ export function registerIpc({
ipcMain.handle(IPC.missionsUpdate, async (_event, arg: UpdateMissionArgs): Promise => {
const ctx = getCtx();
const updated = ctx.missionService.update(arg);
- await safeRefreshMissionPack(ctx, updated.id, "mission_updated");
return updated;
});
@@ -2495,14 +2468,12 @@ export function registerIpc({
ipcMain.handle(IPC.missionsUpdateStep, async (_event, arg: UpdateMissionStepArgs): Promise => {
const ctx = getCtx();
const updated = ctx.missionService.updateStep(arg);
- await safeRefreshMissionPack(ctx, updated.missionId, "mission_step_updated");
return updated;
});
ipcMain.handle(IPC.missionsAddArtifact, async (_event, arg: AddMissionArtifactArgs): Promise => {
const ctx = getCtx();
const artifact = ctx.missionService.addArtifact(arg);
- await safeRefreshMissionPack(ctx, artifact.missionId, "mission_artifact_added");
return artifact;
});
@@ -2511,7 +2482,6 @@ export function registerIpc({
async (_event, arg: AddMissionInterventionArgs): Promise => {
const ctx = getCtx();
const intervention = ctx.missionService.addIntervention(arg);
- await safeRefreshMissionPack(ctx, intervention.missionId, "mission_intervention_added");
return intervention;
}
);
@@ -2521,7 +2491,6 @@ export function registerIpc({
async (_event, arg: ResolveMissionInterventionArgs): Promise => {
const ctx = getCtx();
const intervention = ctx.missionService.resolveIntervention(arg);
- await safeRefreshMissionPack(ctx, intervention.missionId, "mission_intervention_resolved");
return intervention;
}
);
@@ -3724,16 +3693,6 @@ export function registerIpc({
return ctx.agentChatService.warmupModel(arg);
});
- ipcMain.handle(IPC.agentChatListContextPacks, async (_event, arg: ContextPackListArgs = {}): Promise => {
- const ctx = getCtx();
- return ctx.agentChatService.listContextPacks(arg);
- });
-
- ipcMain.handle(IPC.agentChatFetchContextPack, async (_event, arg: ContextPackFetchArgs): Promise => {
- const ctx = getCtx();
- return ctx.agentChatService.fetchContextPack(arg);
- });
-
ipcMain.handle(IPC.agentChatChangePermissionMode, async (_event, arg: AgentChatChangePermissionModeArgs): Promise => {
const ctx = getCtx();
ctx.agentChatService.changePermissionMode(arg);
diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts
index ef845ffb..dceb9019 100644
--- a/apps/desktop/src/main/services/lanes/laneService.ts
+++ b/apps/desktop/src/main/services/lanes/laneService.ts
@@ -13,6 +13,7 @@ import type {
CreateLaneArgs,
DeleteLaneArgs,
LaneIcon,
+ LaneStateSnapshotSummary,
LaneStatus,
LaneSummary,
LaneType,
@@ -55,6 +56,13 @@ type LaneRow = {
status: string;
};
+type LaneStateSnapshotRow = {
+ lane_id: string;
+ agent_summary_json: string | null;
+ mission_summary_json: string | null;
+ updated_at: string | null;
+};
+
const DEFAULT_LANE_STATUS: LaneStatus = { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false };
const LANE_LIST_CACHE_TTL_MS = 10_000;
@@ -113,6 +121,18 @@ function parseLaneTags(raw: string | null): string[] {
}
}
+function parseSummaryRecord(raw: string | null): Record | null {
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw);
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : null;
+ } catch {
+ return null;
+ }
+}
+
function toLaneSummary(args: {
row: LaneRow;
status: LaneStatus;
@@ -721,19 +741,61 @@ export function createLaneService({
return await listLanes(args);
},
- async refreshSnapshots(args: ListLanesArgs = {}): Promise<{ refreshedCount: number }> {
+ getStateSnapshot(laneId: string): LaneStateSnapshotSummary | null {
+ const row = db.get(
+ `
+ select s.lane_id, s.agent_summary_json, s.mission_summary_json, s.updated_at
+ from lane_state_snapshots s
+ join lanes l on l.id = s.lane_id
+ where s.lane_id = ?
+ and l.project_id = ?
+ limit 1
+ `,
+ [laneId, projectId],
+ );
+ if (!row) return null;
+ return {
+ laneId: row.lane_id,
+ agentSummary: parseSummaryRecord(row.agent_summary_json),
+ missionSummary: parseSummaryRecord(row.mission_summary_json),
+ updatedAt: row.updated_at ?? null,
+ };
+ },
+
+ listStateSnapshots(): LaneStateSnapshotSummary[] {
+ return db.all(
+ `
+ select s.lane_id, s.agent_summary_json, s.mission_summary_json, s.updated_at
+ from lane_state_snapshots s
+ join lanes l on l.id = s.lane_id
+ where l.project_id = ?
+ `,
+ [projectId],
+ ).map((row) => ({
+ laneId: row.lane_id,
+ agentSummary: parseSummaryRecord(row.agent_summary_json),
+ missionSummary: parseSummaryRecord(row.mission_summary_json),
+ updatedAt: row.updated_at ?? null,
+ }));
+ },
+
+ async refreshSnapshots(args: ListLanesArgs = {}): Promise<{ refreshedCount: number; lanes: LaneSummary[] }> {
+ invalidateLaneListCache();
const summaries = await listLanes({
includeArchived: args.includeArchived ?? true,
includeStatus: true,
});
- return { refreshedCount: summaries.length };
+ return {
+ refreshedCount: summaries.length,
+ lanes: summaries,
+ };
},
invalidateListCache(): void {
invalidateLaneListCache();
},
- async create({ name, description, parentLaneId }: CreateLaneArgs): Promise {
+ async create({ name, description, parentLaneId, baseBranch }: CreateLaneArgs): Promise {
if (parentLaneId) {
const parent = getLaneRow(parentLaneId);
if (!parent) throw new Error(`Parent lane not found: ${parentLaneId}`);
@@ -759,27 +821,41 @@ export function createLaneService({
}
}
- const parentHeadSha = await getHeadSha(parent.worktree_path);
+ const trimmedBaseBranch = baseBranch?.trim() ?? "";
+ const useCustomBase = parent.lane_type === "primary" && trimmedBaseBranch.length > 0;
+ const requestedBaseRef = useCustomBase ? trimmedBaseBranch : parent.branch_ref;
+ let parentHeadSha: string | null;
+ if (useCustomBase) {
+ const result = await runGit(["rev-parse", requestedBaseRef], { cwd: parent.worktree_path, timeoutMs: 10_000 });
+ if (result.exitCode !== 0 || !result.stdout.trim().length) {
+ throw new Error(`Base branch not found on primary lane: ${requestedBaseRef}`);
+ }
+ parentHeadSha = result.stdout.trim();
+ } else {
+ parentHeadSha = await getHeadSha(parent.worktree_path);
+ }
if (!parentHeadSha) throw new Error(`Unable to resolve parent HEAD for lane ${parent.name}`);
return await createWorktreeLane({
name,
description,
- baseRef: parent.branch_ref,
+ baseRef: requestedBaseRef,
startPoint: parentHeadSha,
parentLaneId: parent.id
});
}
// No parent specified: branch from defaultBaseRef. Resolve the exact SHA to avoid stale refs.
- const headRes = await runGit(["rev-parse", defaultBaseRef], { cwd: projectRoot, timeoutMs: 10_000 });
+ const trimmedBase = baseBranch?.trim() ?? "";
+ const requestedBaseRef = trimmedBase.length > 0 ? trimmedBase : defaultBaseRef;
+ const headRes = await runGit(["rev-parse", requestedBaseRef], { cwd: projectRoot, timeoutMs: 10_000 });
const startPoint = headRes.exitCode === 0 && headRes.stdout.trim().length
? headRes.stdout.trim()
- : defaultBaseRef;
+ : requestedBaseRef;
return await createWorktreeLane({
name,
description,
- baseRef: defaultBaseRef,
+ baseRef: requestedBaseRef,
startPoint,
parentLaneId: null
});
diff --git a/apps/desktop/src/main/services/memory/hybridSearchService.test.ts b/apps/desktop/src/main/services/memory/hybridSearchService.test.ts
index 387d59ef..aaf625ac 100644
--- a/apps/desktop/src/main/services/memory/hybridSearchService.test.ts
+++ b/apps/desktop/src/main/services/memory/hybridSearchService.test.ts
@@ -1,8 +1,42 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
+import { createRequire } from "node:module";
import { afterEach, describe, expect, it, vi } from "vitest";
import { openKvDb } from "../state/kvDb";
+
+type DatabaseSyncLike = {
+ exec: (sql: string) => void;
+ close: () => void;
+};
+
+type DatabaseSyncCtor = new (path: string) => DatabaseSyncLike;
+
+function loadDatabaseSync(): DatabaseSyncCtor | null {
+ try {
+ const req = createRequire(import.meta.url);
+ return (req("node:sqlite") as { DatabaseSync?: DatabaseSyncCtor }).DatabaseSync ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function hasFts(): boolean {
+ const DatabaseSync = loadDatabaseSync();
+ if (!DatabaseSync) return false;
+ let tmp: DatabaseSyncLike | null = null;
+ try {
+ tmp = new DatabaseSync(":memory:");
+ tmp.exec("create virtual table _fts_probe using fts4(content)");
+ return true;
+ } catch {
+ return false;
+ } finally {
+ tmp?.close();
+ }
+}
+
+const ftsAvailable = hasFts();
import { DEFAULT_EMBEDDING_MODEL_ID, EXPECTED_EMBEDDING_DIMENSIONS } from "./embeddingService";
import { createEmbeddingWorkerService } from "./embeddingWorkerService";
import {
@@ -193,7 +227,7 @@ function encodeMatchInfo(values: number[]): Uint8Array {
}
describe("hybridSearchService", () => {
- it("keeps the FTS3 index in sync across insert, update, and delete", async () => {
+ it.skipIf(!ftsAvailable)("keeps the FTS3 index in sync across insert, update, and delete", async () => {
const { db, memoryService, timestamp } = await createFixture({ attachQueueHook: false });
const memory = memoryService.addMemory({
@@ -274,7 +308,7 @@ describe("hybridSearchService", () => {
expect(score).toBeCloseTo(2.8693045687, 5);
});
- it("normalizes BM25 scores to [0, 1] and ranks denser keyword hits higher", async () => {
+ it.skipIf(!ftsAvailable)("normalizes BM25 scores to [0, 1] and ranks denser keyword hits higher", async () => {
const { memoryService, worker, hybridSearchService } = await createFixture();
memoryService.addMemory({
@@ -360,7 +394,7 @@ describe("hybridSearchService", () => {
expect(compositeScore).toBeCloseTo(0.816, 6);
});
- it("re-ranks near-duplicates with MMR to favor diversity", async () => {
+ it.skipIf(!ftsAvailable)("re-ranks near-duplicates with MMR to favor diversity", async () => {
const { memoryService, worker, hybridSearchService } = await createFixture();
const first = memoryService.addMemory({
@@ -398,7 +432,7 @@ describe("hybridSearchService", () => {
expect(rankedIds.indexOf(diverse.id)).toBeLessThan(rankedIds.indexOf(duplicate.id));
});
- it("embeds the query string at search time", async () => {
+ it.skipIf(!ftsAvailable)("embeds the query string at search time", async () => {
const { embeddingService, hybridSearchService } = await createFixture({ attachQueueHook: false });
await hybridSearchService.search({
@@ -410,7 +444,7 @@ describe("hybridSearchService", () => {
expect(embeddingService.embed).toHaveBeenCalledWith("automobile");
});
- it("post-filters vector candidates by project, scope, and scope owner for isolation", async () => {
+ it.skipIf(!ftsAvailable)("post-filters vector candidates by project, scope, and scope owner for isolation", async () => {
const { db, memoryService, worker, hybridSearchService } = await createFixture();
const projectMemory = memoryService.addMemory({
@@ -489,7 +523,7 @@ describe("hybridSearchService", () => {
expect(hits.map((hit) => hit.memory.id)).toEqual([projectMemory.id]);
});
- it("excludes archived entries from hybrid results even when embeddings exist", async () => {
+ it.skipIf(!ftsAvailable)("excludes archived entries from hybrid results even when embeddings exist", async () => {
const { memoryService, worker, hybridSearchService } = await createFixture();
const archived = memoryService.addMemory({
@@ -519,7 +553,7 @@ describe("hybridSearchService", () => {
expect(hits.map((hit) => hit.memory.id)).not.toContain(archived.id);
});
- it("keeps lexical matches without embeddings by using BM25-only hybrid scoring", async () => {
+ it.skipIf(!ftsAvailable)("keeps lexical matches without embeddings by using BM25-only hybrid scoring", async () => {
const { hybridSearchService, memoryService } = await createFixture({ attachQueueHook: false });
const memory = memoryService.addMemory({
@@ -581,7 +615,7 @@ describe("hybridSearchService", () => {
expect(fallback.map((entry) => entry.compositeScore)).toEqual(lexical.map((entry) => entry.compositeScore));
});
- it("finds synonym matches through semantic search", async () => {
+ it.skipIf(!ftsAvailable)("finds synonym matches through semantic search", async () => {
const { memoryService, worker } = await createFixture();
memoryService.addMemory({
@@ -599,7 +633,7 @@ describe("hybridSearchService", () => {
expect(hits[0]!.content).toContain("car repair handbook");
});
- it("makes consolidated entries searchable after the embedding worker processes them", async () => {
+ it.skipIf(!ftsAvailable)("makes consolidated entries searchable after the embedding worker processes them", async () => {
const { db, memoryService, worker } = await createFixture();
const memory = memoryService.writeMemory({
@@ -626,7 +660,7 @@ describe("hybridSearchService", () => {
expect(hits.map((entry) => entry.id)).toContain(memory?.id);
});
- it("supports the full write to queue to embed to search flow", async () => {
+ it.skipIf(!ftsAvailable)("supports the full write to queue to embed to search flow", async () => {
const { db, memoryService, worker } = await createFixture();
const memory = memoryService.addMemory({
diff --git a/apps/desktop/src/main/services/memory/hybridSearchService.ts b/apps/desktop/src/main/services/memory/hybridSearchService.ts
index 0d7f0bdd..80abda72 100644
--- a/apps/desktop/src/main/services/memory/hybridSearchService.ts
+++ b/apps/desktop/src/main/services/memory/hybridSearchService.ts
@@ -368,9 +368,9 @@ export function createHybridSearchService(opts: CreateHybridSearchServiceOpts) {
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
- if (/no such module: fts4/i.test(message) || /no such module: fts5/i.test(message) || /unable to use function matchinfo/i.test(message) || /unable to use function MATCH/i.test(message)) {
- return [];
- }
+ const isFtsMissing = /no such module: fts[45]/i.test(message)
+ || /unable to use function (matchinfo|MATCH)/i.test(message);
+ if (isFtsMissing) return [];
throw error;
}
})();
diff --git a/apps/desktop/src/main/services/memory/memoryLifecycleService.test.ts b/apps/desktop/src/main/services/memory/memoryLifecycleService.test.ts
index f5a20a01..36fd8f52 100644
--- a/apps/desktop/src/main/services/memory/memoryLifecycleService.test.ts
+++ b/apps/desktop/src/main/services/memory/memoryLifecycleService.test.ts
@@ -334,6 +334,7 @@ describe("memoryLifecycleService", () => {
it("enforces the project scope hard limit by archiving the lowest-scoring tier 3 entry", async () => {
const fixture = await createFixture();
let lowestId = "";
+ fixture.db.run("BEGIN");
for (let index = 0; index < 2001; index += 1) {
const id = insertMemory(fixture.db, fixture.now, {
scope: "project",
@@ -346,17 +347,19 @@ describe("memoryLifecycleService", () => {
});
if (index === 0) lowestId = id;
}
+ fixture.db.run("COMMIT");
const result = await fixture.service.runSweep();
expect(result.entriesArchived).toBe(1);
expect(getActiveScopeCount(fixture.db, "project")).toBe(2000);
expect(getMemory(fixture.db, lowestId)?.status).toBe("archived");
- });
+ }, 60_000);
it("enforces the agent scope hard limit per scope owner", async () => {
const fixture = await createFixture();
let lowestId = "";
+ fixture.db.run("BEGIN");
for (let index = 0; index < 501; index += 1) {
const id = insertMemory(fixture.db, fixture.now, {
scope: "agent",
@@ -367,6 +370,7 @@ describe("memoryLifecycleService", () => {
});
if (index === 0) lowestId = id;
}
+ fixture.db.run("COMMIT");
await fixture.service.runSweep();
diff --git a/apps/desktop/src/main/services/missions/missionService.ts b/apps/desktop/src/main/services/missions/missionService.ts
index 63882096..7b208759 100644
--- a/apps/desktop/src/main/services/missions/missionService.ts
+++ b/apps/desktop/src/main/services/missions/missionService.ts
@@ -5,6 +5,7 @@ import {
isValidResolutionKind,
createDefaultComputerUsePolicy,
normalizeComputerUsePolicy,
+ TERMINAL_MISSION_STATUSES,
} from "../../../shared/types";
import type {
AddMissionArtifactArgs,
@@ -26,7 +27,6 @@ import type {
MissionLaneClaimCheckResult,
MissionPhaseConfiguration,
MissionPhaseOverride,
- MissionPlannerRun,
ListMissionsArgs,
MissionArtifact,
MissionArtifactType,
@@ -60,7 +60,6 @@ import type {
UpdateMissionStepArgs
} from "../../../shared/types";
import type { AdeDb } from "../state/kvDb";
-/** Inline type — formerly in the deleted missionPlanningService module. */
type MissionPlanStepDraft = {
index: number;
title: string;
@@ -81,8 +80,6 @@ import { normalizeAgentRuntimeFlags } from "../orchestrator/teamRuntimeConfig";
import { resolveModelDescriptor } from "../../../shared/modelRegistry";
import { normalizeMissionArtifactType as normalizeMissionArtifactTypeValue } from "../../../shared/proofArtifacts";
-const TERMINAL_MISSION_STATUSES = new Set(["completed", "failed", "canceled"]);
-
const ACTIVE_MISSION_STATUSES = new Set(["in_progress", "planning", "intervention_required"]);
const DEFAULT_CONCURRENCY_CONFIG: MissionConcurrencyConfig = {
@@ -240,7 +237,6 @@ type MissionPhaseOverrideRow = {
type CreateMissionInternalArgs = CreateMissionArgs & {
plannedSteps?: MissionPlanStepDraft[];
- plannerRun?: MissionPlannerRun | null;
plannerPlan?: PlannerPlan | null;
};
@@ -2429,7 +2425,6 @@ export function createMissionService({
const priority = args.priority ?? "normal";
const executionMode = args.executionMode ?? "local";
const targetMachineId = coerceNullableString(args.targetMachineId);
- const plannerRun = args.plannerRun ?? null;
const plannerPlan = args.plannerPlan ?? null;
const launchMode = args.launchMode === "manual" ? "manual" : "autopilot";
const autostart = args.autostart !== false;
@@ -2549,36 +2544,6 @@ export function createMissionService({
phaseCount: selectedPhases.length,
phases: selectedPhases
},
- ...(plannerRun
- ? {
- planner: {
- id: plannerRun.id,
- requestedEngine: plannerRun.requestedEngine,
- resolvedEngine: plannerRun.resolvedEngine,
- status: plannerRun.status,
- degraded: plannerRun.degraded,
- reasonCode: plannerRun.reasonCode,
- reasonDetail: plannerRun.reasonDetail,
- planHash: plannerRun.planHash,
- normalizedPlanHash: plannerRun.normalizedPlanHash,
- commandPreview: plannerRun.commandPreview,
- rawResponse: truncateForMetadata(plannerRun.rawResponse, 200_000),
- durationMs: plannerRun.durationMs,
- validationErrors: plannerRun.validationErrors,
- attempts: plannerRun.attempts.map((attempt) => ({
- id: attempt.id,
- engine: attempt.engine,
- status: attempt.status,
- reasonCode: attempt.reasonCode,
- detail: attempt.detail,
- commandPreview: attempt.commandPreview,
- rawResponse: truncateForMetadata(attempt.rawResponse, 50_000),
- validationErrors: attempt.validationErrors,
- createdAt: attempt.createdAt
- }))
- }
- }
- : {}),
plannerPlan: plannerPlan
? {
schemaVersion: plannerPlan.schemaVersion,
@@ -2684,13 +2649,13 @@ export function createMissionService({
executionMode,
targetMachineId,
preview: summarizePrompt(prompt),
- plannerVersion: plannerRun ? "ade.missionPlanner.v2" : "coordinator",
+ plannerVersion: "coordinator",
plannerStrategy: plannerPlan?.missionSummary.strategy ?? "coordinator",
plannerStepCount: stepsToPersist.length,
plannerKeywords: [],
- plannerEngineRequested: plannerRun?.requestedEngine ?? args.plannerEngine ?? "auto",
- plannerEngineResolved: plannerRun?.resolvedEngine ?? null,
- plannerDegraded: plannerRun?.degraded ?? false,
+ plannerEngineRequested: args.plannerEngine ?? "auto",
+ plannerEngineResolved: null,
+ plannerDegraded: false,
phaseProfileId: selectedProfile?.id ?? null,
phaseKeys: selectedPhases.map((phase) => phase.phaseKey)
}
@@ -2708,38 +2673,6 @@ export function createMissionService({
}
});
- if (plannerRun) {
- recordEvent({
- missionId: id,
- eventType: "mission_plan_generated",
- actor: "system",
- summary: `Planner completed with ${plannerRun.resolvedEngine ?? "unknown"}.`,
- payload: {
- plannerRunId: plannerRun.id,
- requestedEngine: plannerRun.requestedEngine,
- resolvedEngine: plannerRun.resolvedEngine,
- status: plannerRun.status,
- degraded: plannerRun.degraded,
- reasonCode: plannerRun.reasonCode,
- reasonDetail: plannerRun.reasonDetail,
- planHash: plannerRun.planHash,
- normalizedPlanHash: plannerRun.normalizedPlanHash,
- commandPreview: plannerRun.commandPreview,
- rawResponse: truncateForMetadata(plannerRun.rawResponse, 8_000),
- durationMs: plannerRun.durationMs,
- validationErrors: plannerRun.validationErrors,
- attempts: plannerRun.attempts.map((attempt) => ({
- id: attempt.id,
- engine: attempt.engine,
- status: attempt.status,
- reasonCode: attempt.reasonCode,
- detail: attempt.detail,
- createdAt: attempt.createdAt
- }))
- }
- });
- }
-
if (plannerPlan) {
const planSummaryText = [
plannerPlan.missionSummary?.strategy ? `Strategy: ${plannerPlan.missionSummary.strategy}` : null,
@@ -2766,21 +2699,6 @@ export function createMissionService({
}
}
- if (plannerRun?.rawResponse?.trim()) {
- insertArtifact({
- missionId: id,
- artifactType: "note",
- title: "Planner output",
- description: truncateForMetadata(plannerRun.rawResponse, 20_000),
- createdBy: "system",
- metadata: {
- plannerRunId: plannerRun.id,
- resolvedEngine: plannerRun.resolvedEngine,
- source: "planner_run",
- },
- });
- }
-
db.flushNow();
emit({ missionId: id, reason: "created" });
const detail = this.get(id);
diff --git a/apps/desktop/src/main/services/missions/phaseEngine.ts b/apps/desktop/src/main/services/missions/phaseEngine.ts
index a5f3b11e..af87130a 100644
--- a/apps/desktop/src/main/services/missions/phaseEngine.ts
+++ b/apps/desktop/src/main/services/missions/phaseEngine.ts
@@ -8,7 +8,6 @@ import type {
MissionPhaseConfiguration,
} from "../../../shared/types";
import { getDefaultModelDescriptor } from "../../../shared/modelRegistry";
-/** Inline type — formerly in the deleted missionPlanningService module. */
type MissionPlanStepDraft = {
index: number;
title: string;
diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts
index b7e7acbb..6cc44ba4 100644
--- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts
+++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts
@@ -1050,7 +1050,6 @@ async function createFixture(args: {
db,
projectId,
projectRoot,
- packService,
projectConfigService
});
const defaultUnifiedModelId = "anthropic/claude-sonnet-4-6";
diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts
index fec48b30..edbc92fa 100644
--- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts
+++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts
@@ -258,7 +258,7 @@ import {
} from "../../../shared/proofArtifacts";
import { getCapabilityForRequirement } from "../computerUse/localComputerUse";
-// ── Intervention Prompt Builder (inlined from deleted planningPipeline module) ──
+// ── Intervention Prompt Builder ──
function buildInterventionResolverPrompt(args: {
missionTitle: string;
@@ -4796,11 +4796,11 @@ Check all worker statuses and continue managing the mission from here. Read work
}
const config = readConfig(projectConfigService);
- if (config.defaultPlannerProvider === "claude" || config.defaultPlannerProvider === "codex") {
- return config.defaultPlannerProvider as "claude" | "codex";
+ if (config.defaultOrchestratorModelId === "claude" || config.defaultOrchestratorModelId === "codex") {
+ return config.defaultOrchestratorModelId as "claude" | "codex";
}
- if (config.defaultPlannerProvider) {
- const desc = getModelById(config.defaultPlannerProvider);
+ if (config.defaultOrchestratorModelId) {
+ const desc = getModelById(config.defaultOrchestratorModelId);
if (desc?.family === "anthropic") return "claude";
if (desc?.family === "openai") return "codex";
}
diff --git a/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts b/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts
index d0994ba3..01c23499 100644
--- a/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts
+++ b/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts
@@ -100,7 +100,6 @@ async function createFixture() {
db,
projectId,
projectRoot,
- packService,
conflictService: undefined,
ptyService,
projectConfigService: null as any,
diff --git a/apps/desktop/src/main/services/orchestrator/missionBudgetService.test.ts b/apps/desktop/src/main/services/orchestrator/missionBudgetService.test.ts
index ad2a3a8b..91ec8cbb 100644
--- a/apps/desktop/src/main/services/orchestrator/missionBudgetService.test.ts
+++ b/apps/desktop/src/main/services/orchestrator/missionBudgetService.test.ts
@@ -327,15 +327,6 @@ describe("missionBudgetService", () => {
db,
projectId,
projectRoot: root,
- packService: {
- getLaneExport: async ({ laneId, level }: { laneId: string; level: "lite" | "standard" | "deep" }) =>
- buildExport(`lane:${laneId}`, "lane", level),
- refreshLanePack: async () => {},
- getProjectExport: async ({ level }: { level: "lite" | "standard" | "deep" }) =>
- buildExport("project", "project", level),
- getDeltaDigest: async () => null,
- getHeadVersion: () => ({ versionId: "pack-v1", versionNumber: 1 })
- } as any,
});
const started = orchestratorService.startRun({
diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts
index f4185892..a14e3017 100644
--- a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts
+++ b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts
@@ -168,7 +168,8 @@ export type OrchestratorHookCommandRunner = (args: {
}) => Promise;
export type ResolvedOrchestratorConfig = {
- defaultPlannerProvider: string | null;
+ /** Resolved model ID for the orchestrator (from defaultOrchestratorModel). */
+ defaultOrchestratorModelId: string | null;
defaultExecutionPolicy: Partial | null;
defaultMissionLevelSettings: MissionLevelSettings | null;
hooks: ResolvedOrchestratorHooks;
@@ -901,9 +902,11 @@ export function readConfig(projectConfigService: ReturnType) : {};
- const defaultPlannerProviderRaw = asString(orchestrator.defaultPlannerProvider);
- const defaultPlannerProvider: string | null =
- defaultPlannerProviderRaw && defaultPlannerProviderRaw.trim().length > 0 ? defaultPlannerProviderRaw.trim() : null;
+ // Prefer defaultOrchestratorModel.modelId; fall back to legacy defaultPlannerProvider.
+ const orcModel = isRecord(orchestrator.defaultOrchestratorModel) ? orchestrator.defaultOrchestratorModel : null;
+ const orcModelId = orcModel && typeof orcModel.modelId === "string" ? orcModel.modelId.trim() : null;
+ const legacyProvider = asString(orchestrator.defaultPlannerProvider)?.trim() || null;
+ const defaultOrchestratorModelId = orcModelId || legacyProvider;
const defaultExecutionPolicy = isRecord(orchestrator.defaultExecutionPolicy)
? (orchestrator.defaultExecutionPolicy as Partial)
: null;
@@ -912,7 +915,7 @@ export function readConfig(projectConfigService: ReturnType;
projectConfigService?: Record | null;
aiIntegrationService?: Record | null;
memoryService?: Record | null;
@@ -147,34 +146,10 @@ async function createFixture(args: {
}
} as any;
- const packService = {
- getLaneExport: async ({ laneId: targetLaneId, level }: { laneId: string; level: "lite" | "standard" | "deep" }) =>
- buildExport(`lane:${targetLaneId}`, "lane", level),
- getProjectExport: async ({ level }: { level: "lite" | "standard" | "deep" }) => buildExport("project", "project", level),
- refreshMissionPack: async ({ missionId: targetMissionId }: { missionId: string }) => ({
- packKey: `mission:${targetMissionId}`,
- packType: "mission",
- path: path.join(projectRoot, ".ade", "packs", "missions", targetMissionId, "mission_pack.md"),
- exists: true,
- deterministicUpdatedAt: now,
- narrativeUpdatedAt: null,
- lastHeadSha: null,
- versionId: `mission-${targetMissionId}-v1`,
- versionNumber: 1,
- contentHash: `hash-mission-${targetMissionId}`,
- metadata: null,
- body: "# Mission Pack"
- })
- } as any;
-
const service = createOrchestratorService({
db,
projectId,
projectRoot,
- packService: {
- ...packService,
- ...(args.packService ?? {})
- } as any,
conflictService: args.conflictService,
ptyService,
projectConfigService: (args.projectConfigService ?? null) as any,
@@ -3378,43 +3353,6 @@ describe("orchestratorService", () => {
}
});
- it("creates context snapshots without lane pack bootstrap refresh", async () => {
- let laneExportCalls = 0;
- const fixture = await createFixture({
- packService: {
- getLaneExport: async ({ laneId, level }: { laneId: string; level: "lite" | "standard" | "deep" }) => {
- laneExportCalls += 1;
- return buildExport(`lane:${laneId}`, "lane", level);
- },
- refreshLanePack: async () => {
- throw new Error("refreshLanePack should not be used for live context exports");
- }
- }
- });
- try {
- const started = fixture.service.startRun({
- missionId: fixture.missionId,
- steps: [{ stepKey: "bootstrap-pack", title: "Bootstrap pack", stepIndex: 0, laneId: fixture.laneId }]
- });
- const step = fixture.service.listSteps(started.run.id)[0];
- if (!step) throw new Error("Missing step");
- const attempt = await fixture.service.startAttempt({
- runId: started.run.id,
- stepId: step.id,
- ownerId: "owner"
- });
- const snapshot = fixture.service
- .listContextSnapshots({ runId: started.run.id })
- .find((entry) => entry.id === attempt.contextSnapshotId);
- expect(attempt.contextSnapshotId).toBeTruthy();
- expect(laneExportCalls).toBe(1);
- expect(snapshot?.cursor.contextSources?.some((source) => source.startsWith("context_export:project:"))).toBe(true);
- expect(snapshot?.cursor.contextSources?.some((source) => source.startsWith("context_export:lane:"))).toBe(true);
- } finally {
- fixture.dispose();
- }
- });
-
it("normalizes adapter envelopes and supports deterministic integration chain blocking", async () => {
const conflictService = {
prepareResolverSession: async () => ({
diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts
index 40ab40c7..44789540 100644
--- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts
+++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts
@@ -76,7 +76,6 @@ import {
import { resolveClaudeCliModel, resolveCodexCliModel } from "../ai/claudeModelUtils";
import { runGit } from "../git/git";
import type { AdeDb, SqlValue } from "../state/kvDb";
-import type { createPackService } from "../packs/packService";
import type { createPtyService } from "../pty/ptyService";
import type { createAgentChatService } from "../chat/agentChatService";
import type { createConflictService } from "../conflicts/conflictService";
@@ -97,7 +96,7 @@ import {
parseAgentChatTranscript,
} from "../../../shared/chatTranscript";
import { isWorkerBootstrapNoiseLine } from "../../../shared/workerRuntimeNoise";
-import { deriveSessionSummaryFromText } from "../packs/transcriptInsights";
+import { deriveSessionSummaryFromText } from "../shared/transcriptInsights";
import {
type RunRow, type StepRow, type AttemptRow, type ClaimRow,
type ContextSnapshotRow, type HandoffRow, type TimelineRow,
@@ -140,8 +139,8 @@ import { resolveAdeLayout } from "../../../shared/adeLayout";
type CreateSnapshotResult = {
snapshotId: string;
cursor: OrchestratorContextSnapshotCursor;
- laneExport: PackExport | null;
- projectExport: PackExport;
+ laneExport: { content: string; truncated: boolean } | null;
+ projectExport: { content: string; truncated: boolean };
docsRefs: OrchestratorDocsRef[];
fullDocs: Array<{ path: string; content: string; truncated: boolean }>;
};
@@ -196,8 +195,8 @@ export type OrchestratorExecutorStartArgs = {
/** All steps in the run, for building a compact plan view in the worker prompt. */
allSteps: OrchestratorStep[];
contextProfile: OrchestratorContextPolicyProfile;
- laneExport: PackExport | null;
- projectExport: PackExport;
+ laneExport: { content: string; truncated: boolean } | null;
+ projectExport: { content: string; truncated: boolean };
docsRefs: OrchestratorDocsRef[];
fullDocs: Array<{ path: string; content: string; truncated: boolean }>;
createTrackedSession: (args: Omit & { tracked?: boolean }) => Promise<{ ptyId: string; sessionId: string }>;
@@ -367,6 +366,9 @@ type PainPointCounter = Map;
const RETROSPECTIVE_PATTERN_PROMOTION_THRESHOLD = 2;
const RETROSPECTIVE_TREND_LOOKBACK_LIMIT = 50;
const ISO_8601_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/;
+const RETRY_BASE_MS = 10_000;
+const RETRY_MULTIPLIER = 2;
+const RETRY_MAX_MS = 300_000;
function normalizeReflectionSignalType(value: string): OrchestratorReflectionEntry["signalType"] | null {
if (
@@ -791,7 +793,6 @@ export function createOrchestratorService({
db,
projectId,
projectRoot,
- packService,
conflictService,
ptyService,
agentChatService,
@@ -810,7 +811,6 @@ export function createOrchestratorService({
db: AdeDb;
projectId: string;
projectRoot: string;
- packService: ReturnType;
conflictService?: ReturnType;
ptyService?: ReturnType;
agentChatService?: ReturnType | null;
@@ -2505,15 +2505,8 @@ export function createOrchestratorService({
const laneExportLevel = args.contextProfile.laneExportLevel;
const projectExportLevel = stepType === "analysis" ? "standard" : args.contextProfile.projectExportLevel;
const lanePackKey = args.step.laneId ? `lane:${args.step.laneId}` : null;
- const laneExport = args.step.laneId
- ? await packService.getLaneExport({
- laneId: args.step.laneId,
- level: laneExportLevel
- })
- : null;
- const projectExport = await packService.getProjectExport({
- level: projectExportLevel
- });
+ const laneExport: { content: string; truncated: boolean } | null = null;
+ const projectExport: { content: string; truncated: boolean } = { content: "", truncated: false };
const docsPaths = readDocPaths(projectRoot);
let remainingBytes = args.contextProfile.maxDocBytes;
@@ -2895,16 +2888,16 @@ export function createOrchestratorService({
docsRefsOnly: deepPackV2.docsRefsOnly,
packRefs: [
{
- packKey: projectExport.packKey,
- level: projectExport.level,
- approxTokens: projectExport.approxTokens ?? null
+ packKey: "project",
+ level: projectExportLevel,
+ approxTokens: null
},
- ...(laneExport
+ ...(lanePackKey
? [
{
- packKey: laneExport.packKey,
- level: laneExport.level,
- approxTokens: laneExport.approxTokens ?? null
+ packKey: lanePackKey,
+ level: laneExportLevel,
+ approxTokens: null
}
]
: [])
@@ -2937,21 +2930,15 @@ export function createOrchestratorService({
l2: memoryL2
};
- const laneVersionId =
- laneExport?.header.versionId ??
- laneExport?.header.contentHash ??
- (laneExport ? `live:${laneExport.packKey}:${sha256(laneExport.content)}` : null);
- const projectVersionId =
- projectExport.header.versionId ??
- projectExport.header.contentHash ??
- `live:${projectExport.packKey}:${sha256(projectExport.content)}`;
+ const laneVersionId = laneExport ? `live:${lanePackKey ?? "lane"}:${sha256(Buffer.from(laneExport.content))}` : null;
+ const projectVersionId = `live:project:${sha256(Buffer.from(projectExport.content))}`;
const cursor: OrchestratorContextSnapshotCursor = {
lanePackKey,
lanePackVersionId: laneVersionId,
- lanePackVersionNumber: laneExport?.header.versionNumber ?? null,
+ lanePackVersionNumber: null,
projectPackKey: "project",
projectPackVersionId: projectVersionId,
- projectPackVersionNumber: projectExport.header.versionNumber ?? null,
+ projectPackVersionNumber: null,
packDeltaSince: previousPackDeltaSince,
docs: docsRefs,
packDeltaDigest,
@@ -2966,8 +2953,8 @@ export function createOrchestratorService({
"execution_pack_v2",
"deep_pack_v2",
"memory_hierarchy_v1",
- `context_export:project:${projectExport.level}`,
- ...(lanePackKey ? [`context_export:${lanePackKey}:${laneExport?.level ?? laneExportLevel}`] : []),
+ `context_export:project:${projectExportLevel}`,
+ ...(lanePackKey ? [`context_export:${lanePackKey}:${laneExportLevel}`] : []),
...(packDeltaDigest ? ["delta_digest"] : []),
...(missionHandoffIds.length ? ["mission_handoffs"] : []),
...(missionHandoffDigest ? ["mission_handoff_digest"] : []),
@@ -3822,16 +3809,16 @@ export function createOrchestratorService({
packs: {
lane: args.laneExport
? {
- packKey: args.laneExport.packKey,
- level: args.laneExport.level,
- approxTokens: args.laneExport.approxTokens,
+ packKey: args.step.laneId ? `lane:${args.step.laneId}` : null,
+ level: args.contextProfile.laneExportLevel,
+ approxTokens: null,
contentPreview: clipText(args.laneExport.content, 3_000)
}
: null,
project: {
- packKey: args.projectExport.packKey,
- level: args.projectExport.level,
- approxTokens: args.projectExport.approxTokens,
+ packKey: "project",
+ level: args.contextProfile.projectExportLevel,
+ approxTokens: null,
contentPreview: clipText(args.projectExport.content, 2_000)
}
},
@@ -6586,8 +6573,8 @@ export function createOrchestratorService({
contextSnapshotId: snapshot.snapshotId,
docsMode: contextPolicy.docsMode,
docsRefs: snapshot.docsRefs,
- laneExportLevel: snapshot.laneExport?.level ?? null,
- projectExportLevel: snapshot.projectExport.level,
+ laneExportLevel: contextPolicy.laneExportLevel ?? null,
+ projectExportLevel: contextPolicy.projectExportLevel,
claims: acquiredClaims.map((claim) => ({
id: claim.id,
scopeKind: claim.scopeKind,
@@ -7310,7 +7297,7 @@ export function createOrchestratorService({
if (sessionId && managedLaunch && agentChatService) {
void (async () => {
try {
- await agentChatService.sendMessage({
+ await agentChatService.runSessionTurn({
sessionId,
text: managedLaunch.prompt,
displayText: managedLaunch.displayText,
@@ -7643,11 +7630,7 @@ export function createOrchestratorService({
Number.isFinite(aiRetryBackoffRaw) && aiRetryBackoffRaw >= 0
? Math.min(10 * 60_000, Math.floor(aiRetryBackoffRaw))
: null;
- // Exponential backoff: base 10s, multiplier 2, cap 5min (300s).
- // Priority: explicit caller backoff > AI-suggested > exponential default.
- const RETRY_BASE_MS = 10_000;
- const RETRY_MULTIPLIER = 2;
- const RETRY_MAX_MS = 300_000;
+ // Exponential backoff with priority: caller backoff > AI-suggested > exponential default.
const exponentialBackoffMs = Math.min(
RETRY_MAX_MS,
Math.floor(RETRY_BASE_MS * Math.pow(RETRY_MULTIPLIER, step.retryCount))
diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts
index 5ed66dd5..93dc6df3 100644
--- a/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts
+++ b/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts
@@ -358,69 +358,10 @@ async function createSmokeFixture() {
})
} as any;
- const packService = {
- getLaneExport: async ({ laneId: targetLaneId, level }: { laneId: string; level: "lite" | "standard" | "deep" }) =>
- buildExport(`lane:${targetLaneId}`, "lane", level),
- getProjectExport: async ({ level }: { level: "lite" | "standard" | "deep" }) => buildExport("project", "project", level),
- getHeadVersion: ({ packKey }: { packKey: string }) => ({
- packKey,
- packType: packKey.startsWith("lane:") ? "lane" : "project",
- versionId: `${packKey}-v1`,
- versionNumber: 1,
- contentHash: `hash-${packKey}`,
- updatedAt: now
- }),
- getDeltaDigest: async (): Promise => ({
- packKey: `lane:${laneId}`,
- packType: "lane",
- since: {
- sinceVersionId: null,
- sinceTimestamp: now,
- baselineVersionId: null,
- baselineVersionNumber: null,
- baselineCreatedAt: null
- },
- newVersion: {
- packKey: `lane:${laneId}`,
- packType: "lane",
- versionId: `lane:${laneId}-v1`,
- versionNumber: 1,
- contentHash: "hash",
- updatedAt: now
- },
- changedSections: [],
- highImpactEvents: [],
- blockers: [],
- conflicts: null,
- decisionState: {
- recommendedExportLevel: "standard",
- reasons: []
- },
- handoffSummary: "none",
- clipReason: null,
- omittedSections: null
- }),
- refreshMissionPack: async ({ missionId }: { missionId: string }) => ({
- packKey: `mission:${missionId}`,
- packType: "mission",
- path: path.join(projectRoot, ".ade", "packs", "missions", missionId, "mission_pack.md"),
- exists: true,
- deterministicUpdatedAt: now,
- narrativeUpdatedAt: null,
- lastHeadSha: null,
- versionId: `mission-${missionId}-v1`,
- versionNumber: 1,
- contentHash: `hash-mission-${missionId}`,
- metadata: null,
- body: "# Mission Pack"
- })
- } as any;
-
const orchestratorService = createOrchestratorService({
db,
projectId,
projectRoot,
- packService,
projectConfigService
});
const aiOrchestratorService = createAiOrchestratorService({
@@ -672,64 +613,6 @@ describe("orchestrator smoke", () => {
})
} as any;
- const packService = {
- getLaneExport: async ({ laneId: targetLaneId, level }: { laneId: string; level: "lite" | "standard" | "deep" }) =>
- buildExport(`lane:${targetLaneId}`, "lane", level),
- getProjectExport: async ({ level }: { level: "lite" | "standard" | "deep" }) => buildExport("project", "project", level),
- getHeadVersion: ({ packKey }: { packKey: string }) => ({
- packKey,
- packType: packKey.startsWith("lane:") ? "lane" : "project",
- versionId: `${packKey}-v1`,
- versionNumber: 1,
- contentHash: `hash-${packKey}`,
- updatedAt: now
- }),
- getDeltaDigest: async (): Promise => ({
- packKey: `lane:${laneId}`,
- packType: "lane",
- since: {
- sinceVersionId: null,
- sinceTimestamp: now,
- baselineVersionId: null,
- baselineVersionNumber: null,
- baselineCreatedAt: null
- },
- newVersion: {
- packKey: `lane:${laneId}`,
- packType: "lane",
- versionId: `lane:${laneId}-v1`,
- versionNumber: 1,
- contentHash: "hash",
- updatedAt: now
- },
- changedSections: [],
- highImpactEvents: [],
- blockers: [],
- conflicts: null,
- decisionState: {
- recommendedExportLevel: "standard",
- reasons: []
- },
- handoffSummary: "none",
- clipReason: null,
- omittedSections: null
- }),
- refreshMissionPack: async ({ missionId }: { missionId: string }) => ({
- packKey: `mission:${missionId}`,
- packType: "mission",
- path: path.join(projectRoot, ".ade", "packs", "missions", missionId, "mission_pack.md"),
- exists: true,
- deterministicUpdatedAt: now,
- narrativeUpdatedAt: null,
- lastHeadSha: null,
- versionId: `mission-${missionId}-v1`,
- versionNumber: 1,
- contentHash: `hash-mission-${missionId}`,
- metadata: null,
- body: "# Mission Pack"
- })
- } as any;
-
const complexPlan = {
schemaVersion: "1.0",
missionSummary: {
@@ -952,7 +835,6 @@ describe("orchestrator smoke", () => {
db,
projectId,
projectRoot,
- packService,
projectConfigService
});
const aiOrchestratorService = createAiOrchestratorService({
diff --git a/apps/desktop/src/main/services/orchestrator/planningFlowAndHandoffs.test.ts b/apps/desktop/src/main/services/orchestrator/planningFlowAndHandoffs.test.ts
index 4076b1cc..d82794ec 100644
--- a/apps/desktop/src/main/services/orchestrator/planningFlowAndHandoffs.test.ts
+++ b/apps/desktop/src/main/services/orchestrator/planningFlowAndHandoffs.test.ts
@@ -82,27 +82,6 @@ async function createFixture() {
]
);
- const packService = {
- getLaneExport: async ({ laneId: lid, level }: { laneId: string; level: string }) =>
- buildExport(`lane:${lid}`, "lane", level as any),
- getProjectExport: async ({ level }: { level: string }) =>
- buildExport("project", "project", level as any),
- refreshMissionPack: async () => ({
- packKey: "mission:pack",
- packType: "mission",
- path: path.join(projectRoot, ".ade", "mission_pack.md"),
- exists: true,
- deterministicUpdatedAt: now,
- narrativeUpdatedAt: null,
- lastHeadSha: null,
- versionId: "v1",
- versionNumber: 1,
- contentHash: "hash",
- metadata: null,
- body: "# Mission Pack",
- }),
- } as any;
-
const ptyService = {
create: async () => ({ ptyId: "pty-1", sessionId: "session-1" }),
} as any;
@@ -111,7 +90,6 @@ async function createFixture() {
db,
projectId,
projectRoot,
- packService,
ptyService,
projectConfigService: null as any,
aiIntegrationService: null as any,
diff --git a/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts b/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts
index 3c53c49f..6f0c9cf1 100644
--- a/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts
+++ b/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts
@@ -439,48 +439,6 @@ describe("VAL-PLAN-004: planner contract enforcement", () => {
db,
projectId,
projectRoot,
- packService: {
- getLaneExport: vi.fn(async () => ({
- packKey: `lane:${laneId}`,
- packType: "lane",
- level: "standard",
- header: {} as any,
- content: "lane",
- approxTokens: 32,
- maxTokens: 500,
- truncated: false,
- warnings: [],
- clipReason: null,
- omittedSections: null,
- })),
- getProjectExport: vi.fn(async () => ({
- packKey: "project",
- packType: "project",
- level: "standard",
- header: {} as any,
- content: "project",
- approxTokens: 32,
- maxTokens: 500,
- truncated: false,
- warnings: [],
- clipReason: null,
- omittedSections: null,
- })),
- refreshMissionPack: vi.fn(async () => ({
- packKey: `mission:${missionId}`,
- packType: "mission",
- path: path.join(projectRoot, ".ade", "packs", "missions", missionId, "mission_pack.md"),
- exists: true,
- deterministicUpdatedAt: now,
- narrativeUpdatedAt: null,
- lastHeadSha: null,
- versionId: `mission-${missionId}-v1`,
- versionNumber: 1,
- contentHash: `hash-mission-${missionId}`,
- metadata: null,
- body: "# Mission Pack",
- })),
- } as any,
ptyService: {
create: vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })),
} as any,
diff --git a/apps/desktop/src/main/services/orchestrator/qualityGateService.ts b/apps/desktop/src/main/services/orchestrator/qualityGateService.ts
deleted file mode 100644
index 1f933b67..00000000
--- a/apps/desktop/src/main/services/orchestrator/qualityGateService.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * qualityGateService.ts
- *
- * Quality evaluation: evaluateQualityGateForStep, handleQualityGateFailure,
- * quality gate helpers.
- *
- * Extracted from aiOrchestratorService.ts — pure refactor, no behavior changes.
- */
-
-import {
- GATE_PHASE_STEP_TYPES,
- QUALITY_GATE_MAX_OUTPUT_CHARS,
-} from "./orchestratorContext";
-
-/**
- * Check whether a step type should trigger quality gate evaluation.
- */
-export function isGatePhaseStepType(stepType: string): boolean {
- return GATE_PHASE_STEP_TYPES.has(stepType.toLowerCase());
-}
-
-/**
- * Clip output for quality gate context.
- */
-export function clipForQualityGate(output: string): string {
- if (output.length <= QUALITY_GATE_MAX_OUTPUT_CHARS) return output;
- return output.slice(0, QUALITY_GATE_MAX_OUTPUT_CHARS - 15) + "... (truncated)";
-}
diff --git a/apps/desktop/src/main/services/orchestrator/runtimeInterventionsSteeringErrors.test.ts b/apps/desktop/src/main/services/orchestrator/runtimeInterventionsSteeringErrors.test.ts
index 70342351..696173a1 100644
--- a/apps/desktop/src/main/services/orchestrator/runtimeInterventionsSteeringErrors.test.ts
+++ b/apps/desktop/src/main/services/orchestrator/runtimeInterventionsSteeringErrors.test.ts
@@ -125,7 +125,6 @@ async function createFixture(opts?: { projectConfigService?: any }) {
db,
projectId,
projectRoot,
- packService,
ptyService,
projectConfigService: opts?.projectConfigService ?? null as any,
aiIntegrationService: null as any,
diff --git a/apps/desktop/src/main/services/orchestrator/stateCoherence.test.ts b/apps/desktop/src/main/services/orchestrator/stateCoherence.test.ts
index 56aaf790..38216df7 100644
--- a/apps/desktop/src/main/services/orchestrator/stateCoherence.test.ts
+++ b/apps/desktop/src/main/services/orchestrator/stateCoherence.test.ts
@@ -122,7 +122,6 @@ async function createFixture() {
db,
projectId,
projectRoot,
- packService,
ptyService,
projectConfigService: null as any,
aiIntegrationService: null as any,
diff --git a/apps/desktop/src/main/services/orchestrator/worktreeIsolation.test.ts b/apps/desktop/src/main/services/orchestrator/worktreeIsolation.test.ts
index c28e83de..7996592d 100644
--- a/apps/desktop/src/main/services/orchestrator/worktreeIsolation.test.ts
+++ b/apps/desktop/src/main/services/orchestrator/worktreeIsolation.test.ts
@@ -121,7 +121,6 @@ async function createFixture(args: {
db,
projectId,
projectRoot,
- packService,
ptyService,
projectConfigService: null as any,
aiIntegrationService: (args.aiIntegrationService ?? null) as any,
diff --git a/apps/desktop/src/main/services/packs/conflictPackBuilder.ts b/apps/desktop/src/main/services/packs/conflictPackBuilder.ts
deleted file mode 100644
index 5bf61275..00000000
--- a/apps/desktop/src/main/services/packs/conflictPackBuilder.ts
+++ /dev/null
@@ -1,321 +0,0 @@
-/**
- * Conflict pack builder — generates conflict packs by running merge-tree
- * analysis and computing overlap. Also provides helpers for reading
- * conflict prediction packs and deriving conflict state.
- */
-
-import fs from "node:fs";
-import path from "node:path";
-import { runGit, runGitMergeTree, runGitOrThrow } from "../git/git";
-import type { createLaneService } from "../lanes/laneService";
-import type {
- GitConflictState,
- LaneSummary,
- LaneLineageV1,
- PackConflictStateV1
-} from "../../../shared/types";
-import { parseDiffNameOnly, uniqueSorted } from "../shared/utils";
-import {
- asString,
- isRecord,
- normalizeConflictStatus,
- type ConflictPredictionPackFile
-} from "./packUtils";
-
-// ── Deps ─────────────────────────────────────────────────────────────────────
-
-export type ConflictPackBuilderDeps = {
- projectRoot: string;
- laneService: ReturnType;
- getConflictPredictionPath: (laneId: string) => string;
- getLanePackPath: (laneId: string) => string;
-};
-
-// ── Conflict prediction reading ──────────────────────────────────────────────
-
-export function readConflictPredictionPack(
- deps: ConflictPackBuilderDeps,
- laneId: string
-): ConflictPredictionPackFile | null {
- const filePath = deps.getConflictPredictionPath(laneId);
- if (!fs.existsSync(filePath)) return null;
- try {
- const raw = fs.readFileSync(filePath, "utf8");
- const parsed = JSON.parse(raw) as unknown;
- if (!isRecord(parsed)) return null;
- return parsed as ConflictPredictionPackFile;
- } catch {
- return null;
- }
-}
-
-// ── Git conflict state ──────────────────────────────────────────────────────
-
-export async function readGitConflictState(
- deps: ConflictPackBuilderDeps,
- laneId: string
-): Promise {
- const lane = deps.laneService.getLaneBaseAndBranch(laneId);
- const gitDirRes = await runGit(["rev-parse", "--absolute-git-dir"], { cwd: lane.worktreePath, timeoutMs: 10_000 });
- const gitDir = gitDirRes.exitCode === 0 ? gitDirRes.stdout.trim() : "";
- const hasRebase =
- gitDir.length > 0 &&
- (fs.existsSync(path.join(gitDir, "rebase-apply")) || fs.existsSync(path.join(gitDir, "rebase-merge")));
- const hasMerge = gitDir.length > 0 && fs.existsSync(path.join(gitDir, "MERGE_HEAD"));
- const kind: GitConflictState["kind"] = hasRebase ? "rebase" : hasMerge ? "merge" : null;
-
- const unmergedRes = await runGit(["diff", "--name-only", "--diff-filter=U"], { cwd: lane.worktreePath, timeoutMs: 10_000 });
- const conflictedFiles =
- unmergedRes.exitCode === 0 ? parseDiffNameOnly(unmergedRes.stdout).sort((a, b) => a.localeCompare(b)) : [];
-
- const inProgress = kind != null;
- return {
- laneId,
- kind,
- inProgress,
- conflictedFiles,
- canContinue: inProgress && conflictedFiles.length === 0,
- canAbort: inProgress
- };
-}
-
-// ── Derive conflict state from prediction pack ──────────────────────────────
-
-export function deriveConflictStateForLane(
- deps: ConflictPackBuilderDeps,
- laneId: string
-): PackConflictStateV1 | null {
- const pack = readConflictPredictionPack(deps, laneId);
- if (!pack || !isRecord(pack.status)) return null;
- const status = pack.status as NonNullable;
- const statusValue = normalizeConflictStatus(asString(status.status).trim()) ?? "unknown";
- const overlappingFileCount = Number(status.overlappingFileCount ?? 0);
- const peerConflictCount = Number(status.peerConflictCount ?? 0);
- const lastPredictedAt = asString(status.lastPredictedAt).trim() || null;
- const strategy = asString(pack.strategy).trim() || undefined;
- const pairwisePairsComputed = Number.isFinite(Number(pack.pairwisePairsComputed)) ? Number(pack.pairwisePairsComputed) : undefined;
- const pairwisePairsTotal = Number.isFinite(Number(pack.pairwisePairsTotal)) ? Number(pack.pairwisePairsTotal) : undefined;
- const lastRecomputedAt = asString(pack.lastRecomputedAt).trim() || asString(pack.generatedAt).trim() || null;
-
- return {
- status: statusValue,
- lastPredictedAt,
- overlappingFileCount: Number.isFinite(overlappingFileCount) ? overlappingFileCount : 0,
- peerConflictCount: Number.isFinite(peerConflictCount) ? peerConflictCount : 0,
- unresolvedPairCount: Number.isFinite(peerConflictCount) ? peerConflictCount : 0,
- truncated: Boolean(pack.truncated),
- strategy,
- pairwisePairsComputed,
- pairwisePairsTotal,
- lastRecomputedAt
- };
-}
-
-// ── Lane lineage ────────────────────────────────────────────────────────────
-
-export function computeLaneLineage(args: { laneId: string; lanesById: Map }): LaneLineageV1 {
- const lane = args.lanesById.get(args.laneId) ?? null;
- const stackDepth = Number(lane?.stackDepth ?? 0);
- const parentLaneId = lane?.parentLaneId ?? null;
- let baseLaneId: string | null = lane?.id ?? args.laneId;
- let cursor = lane;
- const visited = new Set();
- while (cursor?.parentLaneId && !visited.has(cursor.id)) {
- visited.add(cursor.id);
- const parent = args.lanesById.get(cursor.parentLaneId) ?? null;
- if (!parent) break;
- baseLaneId = parent.id;
- cursor = parent;
- }
- return {
- laneId: args.laneId,
- parentLaneId,
- baseLaneId,
- stackDepth: Number.isFinite(stackDepth) ? stackDepth : 0
- };
-}
-
-// ── Conflict risk summary lines ─────────────────────────────────────────────
-
-export function buildLaneConflictRiskSummaryLines(
- deps: ConflictPackBuilderDeps,
- laneId: string
-): string[] {
- const pack = readConflictPredictionPack(deps, laneId);
- if (!pack || !isRecord(pack.status)) return [];
-
- const status = pack.status as NonNullable;
- const statusValue = asString(status.status).trim() || "unknown";
- const overlappingFileCount = Number(status.overlappingFileCount ?? 0);
- const peerConflictCount = Number(status.peerConflictCount ?? 0);
- const lastPredictedAt = asString(status.lastPredictedAt).trim() || null;
-
- const lines: string[] = [];
- lines.push(`- Conflict status: \`${statusValue}\``);
- lines.push(`- Overlapping files: ${Number.isFinite(overlappingFileCount) ? overlappingFileCount : 0}`);
- lines.push(`- Peer conflicts: ${Number.isFinite(peerConflictCount) ? peerConflictCount : 0}`);
- if (lastPredictedAt) lines.push(`- Last predicted: ${lastPredictedAt}`);
- if (asString(pack.generatedAt).trim()) lines.push(`- Generated: ${asString(pack.generatedAt).trim()}`);
-
- const overlaps = Array.isArray(pack.overlaps) ? pack.overlaps : [];
- const riskScore = (riskLevel: string): number => {
- const normalized = riskLevel.trim().toLowerCase();
- if (normalized === "high") return 3;
- if (normalized === "medium") return 2;
- if (normalized === "low") return 1;
- if (normalized === "none") return 0;
- return 0;
- };
-
- const peers = overlaps
- .filter((ov) => ov && ov.peerId != null)
- .map((ov) => {
- const peerName = asString(ov.peerName).trim() || "Unknown lane";
- const riskLevel = asString(ov.riskLevel).trim() || "unknown";
- const fileCount = Array.isArray(ov.files) ? ov.files.length : 0;
- return { peerName, riskLevel, fileCount, score: riskScore(riskLevel) };
- })
- .filter((ov) => ov.score > 0 || ov.fileCount > 0)
- .sort((a, b) => b.score - a.score || b.fileCount - a.fileCount || a.peerName.localeCompare(b.peerName))
- .slice(0, 5);
-
- if (peers.length) {
- lines.push("- Top risky peers:");
- for (const peer of peers) {
- lines.push(` - ${peer.peerName}: \`${peer.riskLevel}\` (${peer.fileCount} files)`);
- }
- }
-
- if (pack.truncated) {
- const strategyValue = asString(pack.strategy).trim() || "partial";
- const computed = Number(pack.pairwisePairsComputed ?? NaN);
- const total = Number(pack.pairwisePairsTotal ?? NaN);
- if (Number.isFinite(computed) && Number.isFinite(total) && total > 0) {
- lines.push(`- Pairwise coverage: ${computed}/${total} pairs (strategy=\`${strategyValue}\`)`);
- } else {
- lines.push(`- Pairwise coverage: partial (strategy=\`${strategyValue}\`)`);
- }
- }
-
- return lines;
-}
-
-// ── Read lane pack excerpt ──────────────────────────────────────────────────
-
-export function readLanePackExcerpt(deps: ConflictPackBuilderDeps, laneId: string): string | null {
- const filePath = deps.getLanePackPath(laneId);
- if (!fs.existsSync(filePath)) return null;
- try {
- const raw = fs.readFileSync(filePath, "utf8");
- const trimmed = raw.trim();
- if (!trimmed) return null;
- const MAX = 12_000;
- return trimmed.length > MAX ? `${trimmed.slice(0, MAX)}\n\n…(truncated)…\n` : trimmed;
- } catch {
- return null;
- }
-}
-
-// ── Build conflict pack body ────────────────────────────────────────────────
-
-export async function buildConflictPackBody(
- deps: ConflictPackBuilderDeps,
- args: {
- laneId: string;
- peerLaneId: string | null;
- reason: string;
- deterministicUpdatedAt: string;
- }
-): Promise<{ body: string; lastHeadSha: string | null }> {
- const laneA = deps.laneService.getLaneBaseAndBranch(args.laneId);
- const laneAHead = (await runGitOrThrow(["rev-parse", "HEAD"], { cwd: laneA.worktreePath, timeoutMs: 10_000 })).trim();
- const peerLabel = args.peerLaneId ? `lane:${args.peerLaneId}` : `base:${laneA.baseRef}`;
-
- const laneBHead = args.peerLaneId
- ? (await runGitOrThrow(["rev-parse", "HEAD"], { cwd: deps.laneService.getLaneBaseAndBranch(args.peerLaneId).worktreePath, timeoutMs: 10_000 })).trim()
- : (await runGitOrThrow(["rev-parse", laneA.baseRef], { cwd: deps.projectRoot, timeoutMs: 10_000 })).trim();
-
- const mergeBase = (await runGitOrThrow(["merge-base", laneAHead, laneBHead], { cwd: deps.projectRoot, timeoutMs: 12_000 })).trim();
- const merge = await runGitMergeTree({
- cwd: deps.projectRoot,
- mergeBase,
- branchA: laneAHead,
- branchB: laneBHead,
- timeoutMs: 60_000
- });
-
- const touchedA = await runGit(["diff", "--name-only", `${mergeBase}..${laneAHead}`], { cwd: deps.projectRoot, timeoutMs: 20_000 });
- const touchedB = await runGit(["diff", "--name-only", `${mergeBase}..${laneBHead}`], { cwd: deps.projectRoot, timeoutMs: 20_000 });
- const aFiles = new Set(parseDiffNameOnly(touchedA.stdout));
- const bFiles = new Set(parseDiffNameOnly(touchedB.stdout));
- const overlap = uniqueSorted(Array.from(aFiles).filter((file) => bFiles.has(file)));
-
- const lines: string[] = [];
- lines.push(`# Conflict Pack`);
- lines.push("");
- lines.push(`- Deterministic updated: ${args.deterministicUpdatedAt}`);
- lines.push(`- Trigger: ${args.reason}`);
- lines.push(`- Lane: ${args.laneId}`);
- lines.push(`- Peer: ${peerLabel}`);
- lines.push(`- Merge base: ${mergeBase}`);
- lines.push(`- Lane HEAD: ${laneAHead}`);
- lines.push(`- Peer HEAD: ${laneBHead}`);
- lines.push("");
-
- lines.push("## Overlapping Files");
- if (overlap.length) {
- for (const file of overlap.slice(0, 120)) {
- lines.push(`- ${file}`);
- }
- if (overlap.length > 120) lines.push(`- … (${overlap.length - 120} more)`);
- } else {
- lines.push("- none");
- }
- lines.push("");
-
- lines.push("## Conflicts (merge-tree)");
- if (merge.conflicts.length) {
- for (const conflict of merge.conflicts.slice(0, 30)) {
- lines.push(`### ${conflict.path} (${conflict.conflictType})`);
- if (conflict.markerPreview.trim().length) {
- lines.push("```");
- lines.push(conflict.markerPreview.trim());
- lines.push("```");
- }
- lines.push("");
- }
- if (merge.conflicts.length > 30) {
- lines.push(`(truncated) ${merge.conflicts.length} conflicts total.`);
- lines.push("");
- }
- } else {
- lines.push("- no merge-tree conflicts reported");
- lines.push("");
- }
-
- const lanePackBody = readLanePackExcerpt(deps, args.laneId);
- if (lanePackBody) {
- lines.push("## Lane Pack (Excerpt)");
- lines.push("```");
- lines.push(lanePackBody.trim());
- lines.push("```");
- lines.push("");
- }
-
- if (args.peerLaneId) {
- const peerPackBody = readLanePackExcerpt(deps, args.peerLaneId);
- if (peerPackBody) {
- lines.push("## Peer Lane Pack (Excerpt)");
- lines.push("```");
- lines.push(peerPackBody.trim());
- lines.push("```");
- lines.push("");
- }
- }
-
- lines.push("## Narrative");
- lines.push("Conflict packs are data-heavy: overlap lists, merge-tree conflicts, and lane context excerpts.");
- lines.push("");
-
- return { body: `${lines.join("\n")}\n`, lastHeadSha: laneAHead };
-}
diff --git a/apps/desktop/src/main/services/packs/lanePackTemplate.test.ts b/apps/desktop/src/main/services/packs/lanePackTemplate.test.ts
deleted file mode 100644
index def97a01..00000000
--- a/apps/desktop/src/main/services/packs/lanePackTemplate.test.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { renderLanePackMarkdown } from "./lanePackTemplate";
-
-describe("renderLanePackMarkdown", () => {
- it("renders the lane pack structure without ANSI, narrative, or AI summaries", () => {
- const md = renderLanePackMarkdown({
- packKey: "lane:lane-1",
- projectId: "proj-1",
- laneId: "lane-1",
- laneName: "Test Lane",
- branchRef: "feature/test",
- baseRef: "main",
- headSha: "0123456789abcdef",
- dirty: false,
- ahead: 1,
- behind: 0,
- parentName: null,
- deterministicUpdatedAt: "2026-02-14T00:00:00.000Z",
- trigger: "session_end",
- providerMode: "subscription",
- whatChangedLines: ["src/api: 2 files (src/api/a.ts, src/api/b.ts)"],
- inferredWhyLines: ["abc1234 Add rate limiting"],
- userIntentMarkers: { start: "", end: "" },
- userIntent: "Tighten API behavior under load.",
- taskSpecMarkers: { start: "", end: "" },
- taskSpec: "- Problem: API degrades under burst.\n- Acceptance: p95 < 200ms\n- Non-goals: rewrite auth",
- validationLines: ["Tests: PASS (suite=unit, duration=72ms)"],
- keyFiles: [{ file: "src/api/a.ts", insertions: 10, deletions: 2 }],
- errors: ["\u001b[31mTypeError: boom\u001b[0m at src/api/a.ts:1"],
- sessionsDetailed: [
- {
- when: "14:32",
- tool: "Shell",
- goal: "npm test",
- result: "ok",
- delta: "+10/-2",
- prompt: "Run the unit tests",
- commands: ["npm test"],
- filesTouched: ["src/api/a.ts"],
- errors: []
- }
- ],
- sessionsTotal: 1,
- sessionsRunning: 0,
- nextSteps: ["Sync with base"],
- userTodosMarkers: { start: "", end: "" },
- userTodos: "- [ ] ship it",
- laneDescription: "Implement rate limiting for the public API endpoints"
- });
-
- expect(md).toContain("```json");
- expect(md).toContain("# Lane: Test Lane");
- expect(md).toContain("## Original Intent");
- expect(md).toContain("Implement rate limiting for the public API endpoints");
- expect(md).toContain("## What Changed");
- expect(md).toContain("## Why");
- expect(md).toContain("## Task Spec");
- expect(md).toContain("## Validation");
- expect(md).toContain("## Key Files");
- expect(md).toContain("## Errors & Issues");
- expect(md).toContain("## Sessions");
- expect(md).toContain("### Session 1:");
- expect(md).toContain("**Prompt**: Run the unit tests");
- expect(md).toContain("**Goal**: npm test");
- expect(md).toContain("**Result**: ok");
- expect(md).toContain("**Delta**: +10/-2");
- expect(md).toContain("**Commands**:");
- expect(md).toContain("**Files touched**:");
- expect(md).toContain("## Open Questions / Next Steps");
- expect(md).not.toContain("\u001b");
- // No narrative section
- expect(md).not.toContain("## Narrative");
- expect(md).not.toContain("ADE_NARRATIVE");
- // No AI summaries
- expect(md).not.toContain("Recent summaries:");
- // No narrativeUpdatedAt in JSON header
- expect(md).not.toContain("narrativeUpdatedAt");
- });
-
- it("omits Original Intent section when lane description is empty", () => {
- const md = renderLanePackMarkdown({
- packKey: "lane:lane-2",
- projectId: "proj-1",
- laneId: "lane-2",
- laneName: "Empty Lane",
- branchRef: "feature/empty",
- baseRef: "main",
- headSha: "abcdef1234567890",
- dirty: true,
- ahead: 0,
- behind: 0,
- parentName: "Main Lane",
- deterministicUpdatedAt: "2026-02-14T00:00:00.000Z",
- trigger: "manual",
- providerMode: "guest",
- whatChangedLines: [],
- inferredWhyLines: [],
- userIntentMarkers: { start: "", end: "" },
- userIntent: "",
- taskSpecMarkers: { start: "", end: "" },
- taskSpec: "",
- validationLines: [],
- keyFiles: [],
- errors: [],
- sessionsDetailed: [],
- sessionsTotal: 0,
- sessionsRunning: 0,
- nextSteps: [],
- userTodosMarkers: { start: "", end: "" },
- userTodos: "",
- laneDescription: ""
- });
-
- expect(md).not.toContain("## Original Intent");
- expect(md).toContain("No sessions recorded yet.");
- expect(md).not.toContain("## Narrative");
- });
-
- it("shows session count message when total exceeds displayed", () => {
- const md = renderLanePackMarkdown({
- packKey: "lane:lane-3",
- projectId: "proj-1",
- laneId: "lane-3",
- laneName: "Busy Lane",
- branchRef: "feature/busy",
- baseRef: "main",
- headSha: "1111111122222222",
- dirty: false,
- ahead: 5,
- behind: 2,
- parentName: null,
- deterministicUpdatedAt: "2026-02-14T00:00:00.000Z",
- trigger: "session_end",
- providerMode: "subscription",
- whatChangedLines: [],
- inferredWhyLines: [],
- userIntentMarkers: { start: "", end: "" },
- userIntent: "Test intent",
- taskSpecMarkers: { start: "", end: "" },
- taskSpec: "",
- validationLines: [],
- keyFiles: [],
- errors: [],
- sessionsDetailed: [
- { when: "10:00", tool: "Claude", goal: "fix bug", result: "ok", delta: "+5/-1", prompt: "Fix the auth bug", commands: [], filesTouched: [], errors: [] }
- ],
- sessionsTotal: 50,
- sessionsRunning: 1,
- nextSteps: [],
- userTodosMarkers: { start: "", end: "" },
- userTodos: "",
- laneDescription: ""
- });
-
- expect(md).toContain("Showing 1 most recent sessions out of 50 total.");
- });
-});
diff --git a/apps/desktop/src/main/services/packs/lanePackTemplate.ts b/apps/desktop/src/main/services/packs/lanePackTemplate.ts
deleted file mode 100644
index f099b87f..00000000
--- a/apps/desktop/src/main/services/packs/lanePackTemplate.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import { stripAnsi } from "../../utils/ansiStrip";
-import { CONTEXT_CONTRACT_VERSION, CONTEXT_HEADER_SCHEMA_V1 } from "../../../shared/contextContract";
-import type { PackConflictStateV1, PackDependencyStateV1 } from "../../../shared/types";
-import type { PackGraphEnvelopeV1 } from "../../../shared/contextContract";
-
-function fmtChange(insertions: number | null, deletions: number | null): string {
- if (insertions == null || deletions == null) return "binary";
- return `+${insertions}/-${deletions}`;
-}
-
-function mdCode(value: string): string {
- // Inline code cannot contain backticks without escaping; keep it simple.
- const clean = value.replace(/`/g, "'");
- return `\`${clean}\``;
-}
-
-export function renderLanePackMarkdown(args: {
- packKey: string;
- projectId: string | null;
- laneId: string;
- laneName: string;
- branchRef: string;
- baseRef: string;
- headSha: string | null;
- dirty: boolean;
- ahead: number;
- behind: number;
- parentName: string | null;
- deterministicUpdatedAt: string;
- trigger: string;
- providerMode: string;
- graph?: PackGraphEnvelopeV1 | null;
- dependencyState?: PackDependencyStateV1 | null;
- conflictState?: PackConflictStateV1 | null;
- whatChangedLines: string[];
- inferredWhyLines: string[];
- userIntentMarkers: { start: string; end: string };
- userIntent: string;
- taskSpecMarkers: { start: string; end: string };
- taskSpec: string;
- validationLines: string[];
- keyFiles: Array<{ file: string; insertions: number | null; deletions: number | null }>;
- errors: string[];
- sessionsDetailed: Array<{
- when: string;
- tool: string;
- goal: string;
- result: string;
- delta: string;
- prompt: string;
- commands: string[];
- filesTouched: string[];
- errors: string[];
- }>;
- sessionsTotal: number;
- sessionsRunning: number;
- nextSteps: string[];
- userTodosMarkers: { start: string; end: string };
- userTodos: string;
- laneDescription: string;
-}): string {
- const shortSha = args.headSha ? args.headSha.slice(0, 8) : "unknown";
- const cleanliness = args.dirty ? "dirty" : "clean";
-
- const lines: string[] = [];
- lines.push("```json");
- lines.push(
- JSON.stringify(
- {
- schema: CONTEXT_HEADER_SCHEMA_V1,
- contractVersion: CONTEXT_CONTRACT_VERSION,
- projectId: args.projectId,
- packKey: args.packKey,
- packType: "lane",
- laneId: args.laneId,
- peerKey: null,
- baseRef: args.baseRef,
- headSha: args.headSha,
- deterministicUpdatedAt: args.deterministicUpdatedAt,
- versionId: null,
- versionNumber: null,
- contentHash: null,
- providerMode: args.providerMode,
- graph: args.graph ?? null,
- dependencyState: args.dependencyState ?? null,
- conflictState: args.conflictState ?? null
- },
- null,
- 2
- )
- );
- lines.push("```");
- lines.push("");
- lines.push(`# Lane: ${stripAnsi(args.laneName)}`);
- lines.push(`> Branch: ${mdCode(stripAnsi(args.branchRef))} | Base: ${mdCode(stripAnsi(args.baseRef))} | HEAD: ${mdCode(shortSha)} | ${cleanliness} · ahead ${args.ahead} · behind ${args.behind}`);
- if (args.parentName) lines.push(`> Parent: ${stripAnsi(args.parentName)}`);
- lines.push("");
-
- const laneDesc = stripAnsi(args.laneDescription).trim();
- if (laneDesc) {
- lines.push("## Original Intent");
- lines.push(laneDesc);
- lines.push("");
- }
-
- lines.push("## What Changed");
- if (args.whatChangedLines.length) {
- for (const entry of args.whatChangedLines) lines.push(`- ${stripAnsi(entry)}`);
- } else {
- lines.push("- No changes detected yet.");
- }
- lines.push("");
-
- lines.push("## Why");
- lines.push(args.userIntentMarkers.start);
- lines.push(stripAnsi(args.userIntent).trim().length ? stripAnsi(args.userIntent).trim() : "Intent not set — click to add.");
- lines.push(args.userIntentMarkers.end);
- if (args.inferredWhyLines.length) {
- lines.push("");
- lines.push("Inferred from commits:");
- for (const entry of args.inferredWhyLines) lines.push(`- ${stripAnsi(entry)}`);
- }
- lines.push("");
-
- lines.push("## Task Spec");
- lines.push(args.taskSpecMarkers.start);
- lines.push(stripAnsi(args.taskSpec).trim().length ? stripAnsi(args.taskSpec).trim() : "- (add task spec here)");
- lines.push(args.taskSpecMarkers.end);
- lines.push("");
-
- lines.push("## Validation");
- if (args.validationLines.length) {
- for (const entry of args.validationLines) lines.push(`- ${stripAnsi(entry)}`);
- } else {
- lines.push("- Tests: NOT RUN");
- lines.push("- Lint: NOT RUN");
- }
- lines.push("");
-
- lines.push(`## Key Files (${args.keyFiles.length} files touched)`);
- if (!args.keyFiles.length) {
- lines.push("No files touched.");
- lines.push("");
- } else {
- lines.push("| File | Change |");
- lines.push("|------|--------|");
- for (const row of args.keyFiles.slice(0, 25)) {
- lines.push(`| ${mdCode(stripAnsi(row.file))} | ${fmtChange(row.insertions, row.deletions)} |`);
- }
- lines.push("");
- }
-
- lines.push("## Errors & Issues");
- if (!args.errors.length) {
- lines.push("No errors detected.");
- } else {
- for (const entry of args.errors.slice(0, 30)) lines.push(`- ${stripAnsi(entry)}`);
- }
- lines.push("");
-
- lines.push(`## Sessions (${args.sessionsTotal} total, ${args.sessionsRunning} running)`);
- if (args.sessionsDetailed.length) {
- for (const [idx, row] of args.sessionsDetailed.slice(0, 30).entries()) {
- lines.push(`### Session ${idx + 1}: ${stripAnsi(row.when)} — ${stripAnsi(row.tool)}`);
- const prompt = stripAnsi(row.prompt).trim();
- if (prompt) {
- lines.push(`- **Prompt**: ${prompt}`);
- }
- lines.push(`- **Goal**: ${stripAnsi(row.goal)}`);
- lines.push(`- **Result**: ${stripAnsi(row.result)}`);
- lines.push(`- **Delta**: ${stripAnsi(row.delta)}`);
- if (row.commands.length) {
- lines.push(`- **Commands**: ${row.commands.map((c) => mdCode(stripAnsi(c))).join(", ")}`);
- }
- if (row.filesTouched.length) {
- lines.push(`- **Files touched**: ${row.filesTouched.map((f) => mdCode(stripAnsi(f))).join(", ")}`);
- }
- if (row.errors.length) {
- lines.push(`- **Errors**: ${row.errors.map((e) => stripAnsi(e)).join("; ")}`);
- }
- lines.push("");
- }
- } else {
- lines.push("No sessions recorded yet.");
- lines.push("");
- }
- if (args.sessionsTotal > args.sessionsDetailed.length) {
- lines.push(`Showing ${args.sessionsDetailed.length} most recent sessions out of ${args.sessionsTotal} total.`);
- lines.push("");
- }
-
- lines.push("## Open Questions / Next Steps");
- if (args.nextSteps.length) {
- for (const entry of args.nextSteps) lines.push(`- ${stripAnsi(entry)}`);
- } else {
- lines.push("- (none detected)");
- }
- lines.push("");
- lines.push(args.userTodosMarkers.start);
- lines.push(stripAnsi(args.userTodos).trim().length ? stripAnsi(args.userTodos).trim() : "- (add notes/todos here)");
- lines.push(args.userTodosMarkers.end);
- lines.push("");
-
- lines.push("---");
- lines.push(
- `*Updated: ${stripAnsi(args.deterministicUpdatedAt)} | Trigger: ${stripAnsi(args.trigger)} | Provider: ${stripAnsi(args.providerMode)} | [View history →](ade://packs/versions/${stripAnsi(args.packKey)})*`
- );
- lines.push("");
-
- return `${lines.join("\n")}\n`;
-}
diff --git a/apps/desktop/src/main/services/packs/missionPackBuilder.ts b/apps/desktop/src/main/services/packs/missionPackBuilder.ts
deleted file mode 100644
index f8f70b58..00000000
--- a/apps/desktop/src/main/services/packs/missionPackBuilder.ts
+++ /dev/null
@@ -1,1048 +0,0 @@
-/**
- * Mission / Plan / Feature pack builder — generates context packs for
- * missions, plans (lane-linked), and feature aggregations.
- */
-
-import type { AdeDb } from "../state/kvDb";
-import type { Logger } from "../logging/logger";
-import type { createLaneService } from "../lanes/laneService";
-import type { createProjectConfigService } from "../config/projectConfigService";
-import type {
- TestRunStatus
-} from "../../../shared/types";
-import {
- CONTEXT_HEADER_SCHEMA_V1,
- CONTEXT_CONTRACT_VERSION,
- ADE_INTENT_START,
- ADE_INTENT_END,
- ADE_TASK_SPEC_START,
- ADE_TASK_SPEC_END
-} from "../../../shared/contextContract";
-import type { PackRelation } from "../../../shared/contextContract";
-import { runGit } from "../git/git";
-import {
- asString,
- extractSection,
- extractSectionByHeading,
- humanToolLabel,
- isRecord,
- parseRecord,
- statusFromCode,
- type ConflictPredictionPackFile
-} from "./packUtils";
-
-// ── Deps ─────────────────────────────────────────────────────────────────────
-
-export type MissionPackBuilderDeps = {
- db: AdeDb;
- logger: Logger;
- projectRoot: string;
- projectId: string;
- packsDir: string;
- laneService: ReturnType;
- projectConfigService: ReturnType;
- /** Helpers from the main service that the builder calls back into */
- getLanePackBody: (laneId: string) => Promise;
- readConflictPredictionPack: (laneId: string) => ConflictPredictionPackFile | null;
- getHeadSha: (worktreePath: string) => Promise;
- getPackIndexRow: (packKey: string) => {
- pack_type: string;
- lane_id: string | null;
- pack_path: string;
- deterministic_updated_at: string | null;
- narrative_updated_at: string | null;
- last_head_sha: string | null;
- metadata_json: string | null;
- } | null;
-};
-
-// ── Mission Pack ─────────────────────────────────────────────────────────────
-
-export async function buildMissionPackBody(
- deps: MissionPackBuilderDeps,
- args: {
- missionId: string;
- reason: string;
- deterministicUpdatedAt: string;
- runId?: string | null;
- }
-): Promise<{ body: string; laneId: string | null }> {
- const { db, projectId } = deps;
-
- const mission = db.get<{
- id: string;
- title: string;
- prompt: string;
- lane_id: string | null;
- status: string;
- priority: string;
- execution_mode: string;
- target_machine_id: string | null;
- outcome_summary: string | null;
- last_error: string | null;
- created_at: string;
- updated_at: string;
- started_at: string | null;
- completed_at: string | null;
- }>(
- `
- select
- id,
- title,
- prompt,
- lane_id,
- status,
- priority,
- execution_mode,
- target_machine_id,
- outcome_summary,
- last_error,
- created_at,
- updated_at,
- started_at,
- completed_at
- from missions
- where id = ?
- and project_id = ?
- limit 1
- `,
- [args.missionId, projectId]
- );
- if (!mission?.id) throw new Error(`Mission not found: ${args.missionId}`);
-
- const steps = db.all<{
- id: string;
- step_index: number;
- title: string;
- detail: string | null;
- kind: string;
- status: string;
- lane_id: string | null;
- metadata_json: string | null;
- started_at: string | null;
- completed_at: string | null;
- updated_at: string;
- }>(
- `
- select
- id,
- step_index,
- title,
- detail,
- kind,
- status,
- lane_id,
- metadata_json,
- started_at,
- completed_at,
- updated_at
- from mission_steps
- where mission_id = ?
- and project_id = ?
- order by step_index asc
- `,
- [args.missionId, projectId]
- );
-
- const artifactRows = db.all<{
- id: string;
- artifact_type: string;
- title: string;
- description: string | null;
- lane_id: string | null;
- created_at: string;
- }>(
- `
- select id, artifact_type, title, description, lane_id, created_at
- from mission_artifacts
- where mission_id = ? and project_id = ?
- order by created_at desc
- limit 40
- `,
- [args.missionId, projectId]
- );
-
- const interventionRows = db.all<{
- id: string;
- intervention_type: string;
- status: string;
- title: string;
- body: string;
- requested_action: string | null;
- resolution_note: string | null;
- created_at: string;
- resolved_at: string | null;
- }>(
- `
- select id, intervention_type, status, title, body, requested_action, resolution_note, created_at, resolved_at
- from mission_interventions
- where mission_id = ? and project_id = ?
- order by created_at desc
- limit 40
- `,
- [args.missionId, projectId]
- );
-
- const handoffs = db.all<{
- handoff_type: string;
- producer: string;
- created_at: string;
- payload_json: string | null;
- }>(
- `
- select handoff_type, producer, created_at, payload_json
- from mission_step_handoffs
- where mission_id = ?
- and project_id = ?
- order by created_at desc
- limit 40
- `,
- [args.missionId, projectId]
- );
-
- const runs = db.all<{
- id: string;
- status: string;
- context_profile: string;
- last_error: string | null;
- created_at: string;
- updated_at: string;
- started_at: string | null;
- completed_at: string | null;
- }>(
- `
- select id, status, context_profile, last_error, created_at, updated_at, started_at, completed_at
- from orchestrator_runs
- where mission_id = ?
- and project_id = ?
- order by created_at desc
- limit 20
- `,
- [args.missionId, projectId]
- );
-
- const lines: string[] = [];
-
- // JSON header
- lines.push("```json");
- lines.push(
- JSON.stringify(
- {
- schema: CONTEXT_HEADER_SCHEMA_V1,
- contractVersion: CONTEXT_CONTRACT_VERSION,
- projectId,
- packType: "mission",
- missionId: mission.id,
- laneId: mission.lane_id,
- status: mission.status,
- deterministicUpdatedAt: args.deterministicUpdatedAt,
- stepCount: steps.length,
- runId: args.runId ?? null
- },
- null,
- 2
- )
- );
- lines.push("```");
- lines.push("");
-
- lines.push(`# Mission Pack: ${mission.title}`);
- lines.push(`> Status: ${mission.status} | Priority: ${mission.priority} | Mode: ${mission.execution_mode}`);
- lines.push("");
-
- lines.push("## Original Prompt");
- lines.push("```");
- lines.push(mission.prompt.trim());
- lines.push("```");
- lines.push("");
-
- lines.push("## Mission Metadata");
- lines.push(`- Mission ID: ${mission.id}`);
- lines.push(`- Updated: ${args.deterministicUpdatedAt}`);
- lines.push(`- Trigger: ${args.reason}`);
- lines.push(`- Status: ${mission.status}`);
- lines.push(`- Priority: ${mission.priority}`);
- lines.push(`- Execution mode: ${mission.execution_mode}`);
- if (mission.target_machine_id) lines.push(`- Target machine: ${mission.target_machine_id}`);
- if (args.runId) lines.push(`- Orchestrator run: ${args.runId}`);
- lines.push(`- Created: ${mission.created_at}`);
- lines.push(`- Updated: ${mission.updated_at}`);
- if (mission.started_at) lines.push(`- Started: ${mission.started_at}`);
- if (mission.completed_at) lines.push(`- Completed: ${mission.completed_at}`);
- if (mission.outcome_summary) lines.push(`- Outcome summary: ${mission.outcome_summary}`);
- if (mission.last_error) lines.push(`- Last error: ${mission.last_error}`);
- lines.push("");
-
- // Mission duration
- if (mission.started_at) {
- const endTime = mission.completed_at ?? args.deterministicUpdatedAt;
- lines.push("## Mission Duration");
- lines.push(`- Start: ${mission.started_at}`);
- lines.push(`- End: ${mission.completed_at ?? "(in progress)"}`);
- const startMs = new Date(mission.started_at).getTime();
- const endMs = new Date(endTime).getTime();
- if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs) {
- const durationMin = Math.round((endMs - startMs) / 60_000);
- lines.push(`- Duration: ${durationMin}m`);
- }
- lines.push("");
- }
-
- // Step Progress
- const completedSteps = steps.filter((s) => s.status === "completed").length;
- lines.push("## Step Progress");
- lines.push(`Progress: ${completedSteps}/${steps.length} completed`);
- lines.push("");
- if (!steps.length) {
- lines.push("- No mission steps.");
- lines.push("");
- } else {
- lines.push("| # | Step | Status | Kind | Lane | Started | Completed |");
- lines.push("|---|------|--------|------|------|---------|-----------|");
- for (const step of steps) {
- const detail = step.detail ? ` - ${step.detail.slice(0, 60).replace(/\|/g, "\\|")}` : "";
- lines.push(
- `| ${Number(step.step_index) + 1} | ${step.title.replace(/\|/g, "\\|")}${detail} | ${step.status} | ${step.kind} | ${step.lane_id ?? "-"} | ${step.started_at ?? "-"} | ${step.completed_at ?? "-"} |`
- );
- }
- lines.push("");
-
- // Per-step error history from metadata
- const stepErrors: string[] = [];
- for (const step of steps) {
- const meta = parseRecord(step.metadata_json);
- if (!meta) continue;
- const errors = Array.isArray(meta.errors) ? meta.errors : [];
- const lastError = typeof meta.last_error === "string" ? meta.last_error : null;
- if (errors.length) {
- for (const err of errors.slice(-5)) {
- stepErrors.push(`- Step ${Number(step.step_index) + 1} (${step.title}): ${String(err).slice(0, 200)}`);
- }
- } else if (lastError) {
- stepErrors.push(`- Step ${Number(step.step_index) + 1} (${step.title}): ${lastError.slice(0, 200)}`);
- }
- }
- if (stepErrors.length) {
- lines.push("### Step Error History");
- for (const se of stepErrors.slice(0, 20)) lines.push(se);
- lines.push("");
- }
- }
-
- // Step Timeline
- const timelineSteps = steps.filter((s) => s.started_at || s.completed_at);
- if (timelineSteps.length) {
- lines.push("## Step Timeline");
- const timelineEvents: Array<{ time: string; label: string }> = [];
- for (const step of timelineSteps) {
- if (step.started_at) {
- timelineEvents.push({ time: step.started_at, label: `Step ${Number(step.step_index) + 1} (${step.title}) started` });
- }
- if (step.completed_at) {
- timelineEvents.push({ time: step.completed_at, label: `Step ${Number(step.step_index) + 1} (${step.title}) completed [${step.status}]` });
- }
- }
- timelineEvents.sort((a, b) => a.time.localeCompare(b.time));
- for (const ev of timelineEvents) {
- lines.push(`- ${ev.time}: ${ev.label}`);
- }
- lines.push("");
- }
-
- // Per-step session references
- lines.push("## Step Sessions");
- let hasStepSessions = false;
- for (const step of steps) {
- if (!step.lane_id) continue;
- const stepSessions = db.all<{
- id: string;
- title: string;
- tool_type: string | null;
- started_at: string;
- ended_at: string | null;
- status: string;
- exit_code: number | null;
- }>(
- `
- select id, title, tool_type, started_at, ended_at, status, exit_code
- from terminal_sessions
- where lane_id = ?
- and started_at >= ?
- order by started_at asc
- limit 8
- `,
- [step.lane_id, step.started_at ?? step.updated_at]
- );
- if (stepSessions.length) {
- hasStepSessions = true;
- lines.push(`### Step ${Number(step.step_index) + 1}: ${step.title}`);
- for (const sess of stepSessions) {
- const tool = humanToolLabel(sess.tool_type);
- const outcome = sess.status === "running" ? "RUNNING" : sess.exit_code === 0 ? "OK" : sess.exit_code != null ? `EXIT ${sess.exit_code}` : "ENDED";
- lines.push(`- ${sess.started_at} | ${tool} | ${(sess.title ?? "").slice(0, 60)} | ${outcome}`);
- }
- lines.push("");
- }
- }
- if (!hasStepSessions) {
- lines.push("- No per-step sessions recorded.");
- lines.push("");
- }
-
- // Artifacts
- lines.push("## Artifacts");
- if (!artifactRows.length) {
- lines.push("- No artifacts recorded.");
- } else {
- lines.push(`Total: ${artifactRows.length}`);
- lines.push("");
- for (const art of artifactRows) {
- const desc = art.description ? ` - ${art.description.slice(0, 100)}` : "";
- lines.push(`- [${art.artifact_type}] ${art.title}${desc} (${art.created_at})`);
- }
- }
- lines.push("");
-
- // Interventions
- lines.push("## Interventions");
- const openInterventions = interventionRows.filter((i) => i.status === "open").length;
- if (!interventionRows.length) {
- lines.push("- No interventions recorded.");
- } else {
- lines.push(`Total: ${interventionRows.length} (${openInterventions} open)`);
- lines.push("");
- for (const intv of interventionRows) {
- lines.push(`- [${intv.status}] ${intv.intervention_type}: ${intv.title}`);
- if (intv.body.trim()) lines.push(` ${intv.body.trim().slice(0, 200)}`);
- if (intv.requested_action) lines.push(` Requested: ${intv.requested_action.slice(0, 150)}`);
- if (intv.resolution_note) lines.push(` Resolution: ${intv.resolution_note.slice(0, 150)}`);
- }
- }
- lines.push("");
-
- // Orchestrator Runs
- lines.push("## Orchestrator Runs");
- if (!runs.length) {
- lines.push("- No orchestrator runs linked yet.");
- } else {
- for (const run of runs) {
- const duration = run.started_at && run.completed_at
- ? `${Math.round((new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()) / 60_000)}m`
- : run.started_at ? "in progress" : "-";
- lines.push(`- ${run.id} | ${run.status} | profile=${run.context_profile} | duration=${duration} | updated=${run.updated_at}`);
- if (run.last_error) lines.push(` error: ${run.last_error.slice(0, 200)}`);
- }
- }
- lines.push("");
-
- // Step Handoffs
- lines.push("## Step Handoffs");
- if (!handoffs.length) {
- lines.push("- No step handoffs recorded.");
- } else {
- for (const handoff of handoffs) {
- const payload = parseRecord(handoff.payload_json);
- const summary = payload?.result && typeof payload.result === "object"
- ? String((payload.result as Record).summary ?? "")
- : "";
- lines.push(
- `- ${handoff.created_at} | ${handoff.handoff_type} | producer=${handoff.producer}${summary ? ` | ${summary}` : ""}`
- );
- }
- }
- lines.push("");
-
- if (mission.lane_id) {
- const lanePack = await deps.getLanePackBody(mission.lane_id);
- if (lanePack.trim().length) {
- lines.push("## Lane Context Reference");
- lines.push(`- Lane context key: lane:${mission.lane_id}`);
- lines.push("- Lane context source: live lane export compatibility view");
- lines.push("");
- }
- }
-
- lines.push("---");
- lines.push(`*Mission pack: deterministic context snapshot. Updated: ${args.deterministicUpdatedAt}*`);
- lines.push("");
-
- return {
- body: `${lines.join("\n")}\n`,
- laneId: mission.lane_id ?? null
- };
-}
-
-// ── Plan Pack ────────────────────────────────────────────────────────────────
-
-export async function buildPlanPackBody(
- deps: MissionPackBuilderDeps,
- args: {
- laneId: string;
- reason: string;
- deterministicUpdatedAt: string;
- }
-): Promise<{ body: string; headSha: string | null }> {
- const { db, projectId } = deps;
- const lanes = await deps.laneService.list({ includeArchived: true });
- const lane = lanes.find((l) => l.id === args.laneId);
- if (!lane) throw new Error(`Lane not found: ${args.laneId}`);
-
- const { worktreePath } = deps.laneService.getLaneBaseAndBranch(args.laneId);
- const headSha = await deps.getHeadSha(worktreePath);
-
- const lines: string[] = [];
-
- // JSON header
- lines.push("```json");
- lines.push(
- JSON.stringify(
- {
- schema: CONTEXT_HEADER_SCHEMA_V1,
- contractVersion: CONTEXT_CONTRACT_VERSION,
- projectId,
- packType: "plan",
- laneId: args.laneId,
- headSha,
- deterministicUpdatedAt: args.deterministicUpdatedAt
- },
- null,
- 2
- )
- );
- lines.push("```");
- lines.push("");
-
- // Check if a mission is linked to this lane
- const mission = db.get<{
- id: string;
- title: string;
- prompt: string;
- status: string;
- priority: string;
- created_at: string;
- updated_at: string;
- started_at: string | null;
- completed_at: string | null;
- }>(
- `
- select id, title, prompt, status, priority, created_at, updated_at, started_at, completed_at
- from missions
- where lane_id = ? and project_id = ?
- order by updated_at desc
- limit 1
- `,
- [args.laneId, projectId]
- );
-
- if (mission?.id) {
- lines.push(`# Plan: ${mission.title}`);
- lines.push(`> Lane: ${lane.name} | Mission: ${mission.id} | Status: ${mission.status} | Priority: ${mission.priority}`);
- lines.push("");
-
- lines.push("## Original Prompt");
- lines.push("```");
- lines.push(mission.prompt.trim());
- lines.push("```");
- lines.push("");
-
- lines.push("## Mission Metadata");
- lines.push(`- Mission ID: ${mission.id}`);
- lines.push(`- Status: ${mission.status}`);
- lines.push(`- Priority: ${mission.priority}`);
- lines.push(`- Created: ${mission.created_at}`);
- lines.push(`- Updated: ${mission.updated_at}`);
- if (mission.started_at) lines.push(`- Started: ${mission.started_at}`);
- if (mission.completed_at) lines.push(`- Completed: ${mission.completed_at}`);
- lines.push("");
-
- const steps = db.all<{
- id: string;
- step_index: number;
- title: string;
- detail: string | null;
- kind: string;
- status: string;
- lane_id: string | null;
- metadata_json: string | null;
- started_at: string | null;
- completed_at: string | null;
- }>(
- `
- select id, step_index, title, detail, kind, status, lane_id, metadata_json, started_at, completed_at
- from mission_steps
- where mission_id = ? and project_id = ?
- order by step_index asc
- `,
- [mission.id, projectId]
- );
-
- const completedSteps = steps.filter((s) => s.status === "completed").length;
- lines.push("## Steps");
- lines.push(`Progress: ${completedSteps}/${steps.length} completed`);
- lines.push("");
-
- if (steps.length === 0) {
- lines.push("- No steps defined yet.");
- } else {
- lines.push("| # | Step | Status | Kind | Started | Completed |");
- lines.push("|---|------|--------|------|---------|-----------|");
- for (const step of steps) {
- const desc = step.detail ? ` - ${step.detail.slice(0, 80).replace(/\|/g, "\\|")}` : "";
- lines.push(
- `| ${Number(step.step_index) + 1} | ${step.title.replace(/\|/g, "\\|")}${desc} | ${step.status} | ${step.kind} | ${step.started_at ?? "-"} | ${step.completed_at ?? "-"} |`
- );
- }
-
- const depsLines: string[] = [];
- for (const step of steps) {
- const meta = parseRecord(step.metadata_json);
- const stepDeps = meta && Array.isArray(meta.dependencies) ? meta.dependencies : [];
- if (stepDeps.length) {
- depsLines.push(`- Step ${Number(step.step_index) + 1} (${step.title}): depends on ${stepDeps.join(", ")}`);
- }
- }
- if (depsLines.length) {
- lines.push("");
- lines.push("### Step Dependencies");
- for (const dl of depsLines) lines.push(dl);
- }
- }
- lines.push("");
-
- // Timeline
- const timelineEntries = steps
- .filter((s) => s.started_at || s.completed_at)
- .sort((a, b) => (a.started_at ?? a.completed_at ?? "").localeCompare(b.started_at ?? b.completed_at ?? ""));
- if (timelineEntries.length) {
- lines.push("## Timeline");
- for (const step of timelineEntries) {
- const start = step.started_at ?? "-";
- const end = step.completed_at ?? "-";
- lines.push(`- Step ${Number(step.step_index) + 1} (${step.title}): started=${start}, completed=${end}`);
- }
- lines.push("");
- }
-
- // Handoff and retry policies from mission metadata
- const missionMeta = db.get<{ metadata_json: string | null }>(
- "select metadata_json from missions where id = ? and project_id = ?",
- [mission.id, projectId]
- );
- const missionMetaParsed = parseRecord(missionMeta?.metadata_json);
- if (missionMetaParsed) {
- const policies: string[] = [];
- if (missionMetaParsed.handoffPolicy) policies.push(`- Handoff policy: ${JSON.stringify(missionMetaParsed.handoffPolicy)}`);
- if (missionMetaParsed.retryPolicy) policies.push(`- Retry policy: ${JSON.stringify(missionMetaParsed.retryPolicy)}`);
- if (policies.length) {
- lines.push("## Policies");
- for (const p of policies) lines.push(p);
- lines.push("");
- }
- }
- } else {
- // No mission linked: structured template
- const lanePackBody = await deps.getLanePackBody(args.laneId);
- const intent = extractSection(lanePackBody, ADE_INTENT_START, ADE_INTENT_END, "");
- const taskSpec = extractSection(lanePackBody, ADE_TASK_SPEC_START, ADE_TASK_SPEC_END, "");
-
- lines.push(`# Plan: ${lane.name}`);
- lines.push(`> Lane: ${lane.name} | Branch: \`${lane.branchRef}\` | No mission linked`);
- lines.push("");
-
- lines.push("## Objective");
- lines.push(intent.trim().length ? intent.trim() : "Not yet defined");
- lines.push("");
-
- lines.push("## Current State");
- const keyFilesMatch = /## Key Files \((\d+) files touched\)/.exec(lanePackBody);
- const fileCount = keyFilesMatch ? keyFilesMatch[1] : "0";
- lines.push(`- Files changed: ${fileCount}`);
- lines.push(`- Branch status: ${lane.status.dirty ? "dirty" : "clean"}, ahead ${lane.status.ahead}, behind ${lane.status.behind}`);
-
- const latestTest = db.get<{ status: string; suite_name: string | null; suite_key: string }>(
- `
- select r.status as status, s.name as suite_name, r.suite_key as suite_key
- from test_runs r
- left join test_suites s on s.project_id = r.project_id and s.id = r.suite_key
- where r.project_id = ? and r.lane_id = ?
- order by r.started_at desc limit 1
- `,
- [projectId, args.laneId]
- );
- if (latestTest) {
- const testLabel = (latestTest.suite_name ?? latestTest.suite_key).trim();
- lines.push(`- Latest test: ${statusFromCode(latestTest.status as TestRunStatus)} (${testLabel})`);
- } else {
- lines.push("- Latest test: NOT RUN");
- }
- lines.push("");
-
- lines.push("## Steps");
- lines.push("- (define steps for this lane's work)");
- lines.push("");
-
- lines.push("## Dependencies");
- const packKey = `lane:${args.laneId}`;
- const packRow = deps.getPackIndexRow(packKey);
- if (packRow?.metadata_json) {
- const packMeta = parseRecord(packRow.metadata_json);
- if (packMeta?.graph && isRecord(packMeta.graph)) {
- const graphRelations = Array.isArray((packMeta.graph as Record).relations)
- ? ((packMeta.graph as Record).relations as PackRelation[])
- : [];
- const blockingRels = graphRelations.filter(
- (r) => r.relationType === "blocked_by" || r.relationType === "depends_on"
- );
- if (blockingRels.length) {
- for (const rel of blockingRels) {
- lines.push(`- ${rel.relationType}: ${rel.targetPackKey}`);
- }
- } else {
- lines.push("- No blocking dependencies detected.");
- }
- } else {
- lines.push("- No blocking dependencies detected.");
- }
- } else {
- lines.push("- No blocking dependencies detected.");
- }
- if (lane.parentLaneId) {
- const parentLane = lanes.find((l) => l.id === lane.parentLaneId);
- if (parentLane) lines.push(`- Parent lane: ${parentLane.name} (\`${parentLane.branchRef}\`)`);
- }
- lines.push("");
-
- lines.push("## Acceptance Criteria");
- if (taskSpec.trim().length) {
- lines.push(taskSpec.trim());
- } else {
- lines.push("- (add acceptance criteria here)");
- }
- lines.push("");
- }
-
- lines.push("---");
- lines.push(`*Plan pack: auto-generated for lane ${lane.name}. Updated: ${args.deterministicUpdatedAt}*`);
- lines.push("");
-
- return { body: `${lines.join("\n")}\n`, headSha };
-}
-
-// ── Feature Pack ─────────────────────────────────────────────────────────────
-
-export async function buildFeaturePackBody(
- deps: MissionPackBuilderDeps,
- args: {
- featureKey: string;
- reason: string;
- deterministicUpdatedAt: string;
- }
-): Promise<{ body: string; laneIds: string[] }> {
- const { db, projectId, projectRoot } = deps;
- const lanes = await deps.laneService.list({ includeArchived: false });
- const matching = lanes.filter((lane) => lane.tags.includes(args.featureKey));
- const lanePackBodyCache = new Map();
- const getLanePackBody = async (laneId: string): Promise => {
- const cached = lanePackBodyCache.get(laneId);
- if (cached != null) return cached;
- const body = await deps.getLanePackBody(laneId);
- lanePackBodyCache.set(laneId, body);
- return body;
- };
- const lines: string[] = [];
-
- // JSON header
- lines.push("```json");
- lines.push(
- JSON.stringify(
- {
- schema: CONTEXT_HEADER_SCHEMA_V1,
- contractVersion: CONTEXT_CONTRACT_VERSION,
- projectId,
- packType: "feature",
- featureKey: args.featureKey,
- deterministicUpdatedAt: args.deterministicUpdatedAt,
- laneCount: matching.length,
- laneIds: matching.map((l) => l.id)
- },
- null,
- 2
- )
- );
- lines.push("```");
- lines.push("");
-
- lines.push(`# Feature Pack: ${args.featureKey}`);
- lines.push(`> Updated: ${args.deterministicUpdatedAt} | Trigger: ${args.reason} | Lanes: ${matching.length}`);
- lines.push("");
-
- if (matching.length === 0) {
- lines.push("No lanes are tagged with this feature key yet.");
- lines.push("");
- lines.push("## How To Use");
- lines.push(`- Add the tag '${args.featureKey}' to one or more lanes (Workspace Graph -> right click lane -> Customize).`);
- lines.push("");
- return { body: `${lines.join("\n")}\n`, laneIds: [] };
- }
-
- // Feature Progress Summary
- const dirtyCount = matching.filter((l) => l.status.dirty).length;
- const cleanCount = matching.length - dirtyCount;
- const totalAhead = matching.reduce((sum, l) => sum + l.status.ahead, 0);
- const totalBehind = matching.reduce((sum, l) => sum + l.status.behind, 0);
-
- lines.push("## Feature Progress Summary");
- lines.push(`- Lanes: ${matching.length} (${dirtyCount} dirty, ${cleanCount} clean)`);
- lines.push(`- Total ahead: ${totalAhead} | Total behind: ${totalBehind}`);
- lines.push("");
-
- // Combined File Changes
- lines.push("## Combined File Changes");
- type FeatureFileDelta = { insertions: number | null; deletions: number | null };
- const featureDeltas = new Map();
-
- for (const lane of matching) {
- const { worktreePath } = deps.laneService.getLaneBaseAndBranch(lane.id);
- const headSha = await deps.getHeadSha(worktreePath);
- const mergeBaseRes = await runGit(
- ["merge-base", headSha ?? "HEAD", lane.baseRef?.trim() || "HEAD"],
- { cwd: projectRoot, timeoutMs: 12_000 }
- );
- const mergeBaseSha = mergeBaseRes.exitCode === 0 ? mergeBaseRes.stdout.trim() : null;
-
- if (mergeBaseSha && (headSha ?? "HEAD") !== mergeBaseSha) {
- const diff = await runGit(
- ["diff", "--numstat", `${mergeBaseSha}..${headSha ?? "HEAD"}`],
- { cwd: projectRoot, timeoutMs: 20_000 }
- );
- if (diff.exitCode === 0) {
- for (const diffLine of diff.stdout.split("\n").map((l) => l.trim()).filter(Boolean)) {
- const parts = diffLine.split("\t");
- if (parts.length < 3) continue;
- const insRaw = parts[0] ?? "0";
- const delRaw = parts[1] ?? "0";
- const filePath = parts.slice(2).join("\t").trim();
- if (!filePath) continue;
- const ins = insRaw === "-" ? null : Number(insRaw);
- const del = delRaw === "-" ? null : Number(delRaw);
- const prev = featureDeltas.get(filePath);
- if (!prev) {
- featureDeltas.set(filePath, {
- insertions: Number.isFinite(ins) ? ins : null,
- deletions: Number.isFinite(del) ? del : null
- });
- } else {
- featureDeltas.set(filePath, {
- insertions: prev.insertions == null || ins == null ? null : prev.insertions + ins,
- deletions: prev.deletions == null || del == null ? null : prev.deletions + del
- });
- }
- }
- }
- }
- }
-
- if (featureDeltas.size === 0) {
- lines.push("No file changes detected across feature lanes.");
- } else {
- const sorted = [...featureDeltas.entries()]
- .sort((a, b) => {
- const aTotal = (a[1].insertions ?? 0) + (a[1].deletions ?? 0);
- const bTotal = (b[1].insertions ?? 0) + (b[1].deletions ?? 0);
- return bTotal - aTotal;
- })
- .slice(0, 40);
-
- lines.push("| File | Change |");
- lines.push("|------|--------|");
- for (const [file, delta] of sorted) {
- const change = delta.insertions == null || delta.deletions == null ? "binary" : `+${delta.insertions}/-${delta.deletions}`;
- lines.push(`| \`${file}\` | ${change} |`);
- }
- if (featureDeltas.size > 40) {
- lines.push(`| ... | ${featureDeltas.size - 40} more files |`);
- }
- }
- lines.push("");
-
- // Rolled-up Test Results
- lines.push("## Rolled-up Test Results");
- let totalPassed = 0;
- let totalFailed = 0;
- let totalOtherTests = 0;
- const failingTests: string[] = [];
-
- for (const lane of matching) {
- const testRows = db.all<{
- run_id: string;
- suite_name: string | null;
- suite_key: string;
- status: string;
- }>(
- `
- select
- r.id as run_id,
- s.name as suite_name,
- r.suite_key as suite_key,
- r.status as status
- from test_runs r
- left join test_suites s on s.project_id = r.project_id and s.id = r.suite_key
- where r.project_id = ?
- and r.lane_id = ?
- order by r.started_at desc
- limit 3
- `,
- [projectId, lane.id]
- );
- for (const tr of testRows) {
- if (tr.status === "passed") totalPassed++;
- else if (tr.status === "failed") {
- totalFailed++;
- failingTests.push(`${lane.name}: ${(tr.suite_name ?? tr.suite_key).trim()}`);
- } else {
- totalOtherTests++;
- }
- }
- }
-
- if (totalPassed + totalFailed + totalOtherTests === 0) {
- lines.push("- No test runs recorded across feature lanes.");
- } else {
- lines.push(`- Passed: ${totalPassed} | Failed: ${totalFailed} | Other: ${totalOtherTests}`);
- if (failingTests.length) {
- lines.push("- Failing tests:");
- for (const ft of failingTests.slice(0, 20)) {
- lines.push(` - ${ft}`);
- }
- }
- }
- lines.push("");
-
- // Cross-Lane Conflict Predictions
- lines.push("## Cross-Lane Conflict Predictions");
- const conflictEntries: string[] = [];
- const matchingIds = new Set(matching.map((l) => l.id));
- for (const lane of matching) {
- const conflictPack = deps.readConflictPredictionPack(lane.id);
- if (!conflictPack) continue;
- const overlaps = Array.isArray(conflictPack.overlaps) ? conflictPack.overlaps : [];
- for (const ov of overlaps) {
- if (!ov || !ov.peerId) continue;
- if (!matchingIds.has(ov.peerId)) continue;
- const peerName = asString(ov.peerName).trim() || ov.peerId;
- const riskLevel = asString(ov.riskLevel).trim() || "unknown";
- const fileCount = Array.isArray(ov.files) ? ov.files.length : 0;
- conflictEntries.push(`- ${lane.name} <-> ${peerName}: risk=\`${riskLevel}\`, ${fileCount} overlapping files`);
- }
- }
- if (conflictEntries.length === 0) {
- lines.push("- No cross-lane conflict predictions within this feature.");
- } else {
- for (const entry of conflictEntries.slice(0, 20)) {
- lines.push(entry);
- }
- }
- lines.push("");
-
- // Combined Session Timeline
- lines.push("## Combined Session Timeline");
- const featureSessions = db.all<{
- id: string;
- lane_id: string;
- title: string;
- tool_type: string | null;
- started_at: string;
- ended_at: string | null;
- status: string;
- exit_code: number | null;
- }>(
- `
- select
- s.id, s.lane_id, s.title, s.tool_type, s.started_at, s.ended_at, s.status, s.exit_code
- from terminal_sessions s
- where s.lane_id in (${matching.map(() => "?").join(",")})
- order by s.started_at desc
- limit 30
- `,
- matching.map((l) => l.id)
- );
-
- if (featureSessions.length === 0) {
- lines.push("- No sessions recorded across feature lanes.");
- } else {
- lines.push("| When | Lane | Tool | Title | Status |");
- lines.push("|------|------|------|-------|--------|");
- const laneNameById = new Map(matching.map((l) => [l.id, l.name]));
- for (const sess of featureSessions) {
- const when = sess.started_at.length >= 16 ? sess.started_at.slice(0, 16) : sess.started_at;
- const laneName = laneNameById.get(sess.lane_id) ?? sess.lane_id;
- const tool = humanToolLabel(sess.tool_type);
- const title = (sess.title ?? "").replace(/\|/g, "\\|").slice(0, 60);
- const status = sess.status === "running" ? "RUNNING" : sess.exit_code === 0 ? "OK" : sess.exit_code != null ? `EXIT ${sess.exit_code}` : "ENDED";
- lines.push(`| ${when} | ${laneName} | ${tool} | ${title} | ${status} |`);
- }
- }
- lines.push("");
-
- // Combined Errors
- lines.push("## Combined Errors");
- const allErrors: string[] = [];
- for (const lane of matching) {
- const lanePackBody = await getLanePackBody(lane.id);
- const errSection = extractSectionByHeading(lanePackBody, "## Errors & Issues");
- if (errSection && errSection.trim() !== "No errors detected.") {
- for (const errLine of errSection.split("\n").map((l) => l.trim()).filter(Boolean)) {
- const cleaned = errLine.startsWith("- ") ? errLine.slice(2) : errLine;
- if (cleaned.length) allErrors.push(`[${lane.name}] ${cleaned}`);
- }
- }
- }
- if (allErrors.length === 0) {
- lines.push("No errors detected across feature lanes.");
- } else {
- for (const err of allErrors.slice(0, 30)) {
- lines.push(`- ${err}`);
- }
- }
- lines.push("");
-
- // Per-Lane Details
- for (const lane of matching.sort((a, b) => a.stackDepth - b.stackDepth || a.name.localeCompare(b.name))) {
- const lanePackBody = await getLanePackBody(lane.id);
- const intent = extractSection(lanePackBody, ADE_INTENT_START, ADE_INTENT_END, "");
-
- const laneTest = db.get<{ status: string; suite_name: string | null; suite_key: string }>(
- `
- select r.status as status, s.name as suite_name, r.suite_key as suite_key
- from test_runs r
- left join test_suites s on s.project_id = r.project_id and s.id = r.suite_key
- where r.project_id = ? and r.lane_id = ?
- order by r.started_at desc limit 1
- `,
- [projectId, lane.id]
- );
-
- const keyFilesMatch = /## Key Files \((\d+) files touched\)/.exec(lanePackBody);
- const laneFileCount = keyFilesMatch ? Number(keyFilesMatch[1]) : 0;
-
- lines.push(`### Lane: ${lane.name}`);
- lines.push(`- Branch: \`${lane.branchRef}\` | Status: ${lane.status.dirty ? "dirty" : "clean"} | Ahead: ${lane.status.ahead} | Behind: ${lane.status.behind}`);
- if (intent.trim().length) {
- lines.push(`- Intent: ${intent.trim().slice(0, 200)}`);
- }
- lines.push(`- Files changed: ${laneFileCount}`);
- if (laneTest) {
- const testLabel = (laneTest.suite_name ?? laneTest.suite_key).trim();
- lines.push(`- Latest test: ${statusFromCode(laneTest.status as TestRunStatus)} (${testLabel})`);
- } else {
- lines.push("- Latest test: NOT RUN");
- }
- lines.push("");
- }
-
- lines.push("---");
- lines.push(`*Feature pack: deterministic aggregation across ${matching.length} lanes. Updated: ${args.deterministicUpdatedAt}*`);
- lines.push("");
-
- return { body: `${lines.join("\n")}\n`, laneIds: matching.map((lane) => lane.id) };
-}
diff --git a/apps/desktop/src/main/services/packs/packDeltaDigest.test.ts b/apps/desktop/src/main/services/packs/packDeltaDigest.test.ts
deleted file mode 100644
index dd1eca72..00000000
--- a/apps/desktop/src/main/services/packs/packDeltaDigest.test.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { describe, expect, it } from "vitest";
-import fs from "node:fs";
-import os from "node:os";
-import path from "node:path";
-import { randomUUID } from "node:crypto";
-import { openKvDb } from "../state/kvDb";
-import { createPackService } from "./packService";
-
-function makeLanePackBody(args: { taskSpec: string; intent: string; narrative?: string }): string {
- return [
- "# Lane: demo",
- "",
- "## Task Spec",
- "",
- args.taskSpec,
- "",
- "",
- "## Why",
- "",
- args.intent,
- "",
- "",
- "## Narrative",
- "",
- args.narrative ?? "",
- "",
- "",
- "## Sessions",
- "| When | Tool | Goal | Result | Delta |",
- "|------|------|------|--------|-------|",
- "| 12:00 | Shell | npm test | ok | +1/-0 |",
- ""
- ].join("\n");
-}
-
-describe("packService.getDeltaDigest", () => {
- it("returns changed sections and injects event meta for legacy payloads", async () => {
- const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-delta-"));
- const packsDir = path.join(tmpRoot, "packs");
- fs.mkdirSync(packsDir, { recursive: true });
-
- const dbPath = path.join(tmpRoot, "kv.sqlite");
- const logger = {
- debug: () => {},
- info: () => {},
- warn: () => {},
- error: () => {}
- } as any;
- const db = await openKvDb(dbPath, logger);
-
- const projectId = "proj-1";
- const now = "2026-02-15T19:00:00.000Z";
- db.run(
- "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)",
- [projectId, tmpRoot, "demo", "main", now, now]
- );
- db.run(
- `
- insert into lanes(
- id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path,
- attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- ["lane-1", projectId, "demo", null, "worktree", "main", "ade/demo", tmpRoot, null, 0, null, null, null, null, "active", now, null]
- );
-
- const packService = createPackService({
- db,
- logger,
- projectRoot: tmpRoot,
- projectId,
- packsDir,
- laneService: { list: async () => [], getLaneBaseAndBranch: () => ({ worktreePath: tmpRoot, baseRef: "main", branchRef: "ade/demo" }) } as any,
- sessionService: { readTranscriptTail: () => "" } as any,
- projectConfigService: { get: () => ({ local: { providers: {} }, effective: { providerMode: "guest", providers: {}, processes: [], testSuites: [], stackButtons: [] } }) } as any,
- operationService: { start: () => ({ operationId: "op-1" }), finish: () => {} } as any
- });
-
- const versionsDir = path.join(packsDir, "versions");
- fs.mkdirSync(versionsDir, { recursive: true });
-
- const beforeId = randomUUID();
- const afterId = randomUUID();
- const beforePath = path.join(versionsDir, `${beforeId}.md`);
- const afterPath = path.join(versionsDir, `${afterId}.md`);
-
- const beforeBody = makeLanePackBody({ taskSpec: "old spec", intent: "old intent" });
- const afterBody = makeLanePackBody({ taskSpec: "new spec", intent: "old intent" });
- fs.writeFileSync(beforePath, beforeBody, "utf8");
- fs.writeFileSync(afterPath, afterBody, "utf8");
-
- const t0 = "2026-02-15T19:01:00.000Z";
- const t1 = "2026-02-15T19:10:00.000Z";
- db.run(
- "insert into pack_versions(id, project_id, pack_key, version_number, content_hash, rendered_path, created_at) values (?, ?, ?, ?, ?, ?, ?)",
- [beforeId, projectId, "lane:lane-1", 1, "hash1", beforePath, t0]
- );
- db.run(
- "insert into pack_versions(id, project_id, pack_key, version_number, content_hash, rendered_path, created_at) values (?, ?, ?, ?, ?, ?, ?)",
- [afterId, projectId, "lane:lane-1", 2, "hash2", afterPath, t1]
- );
- db.run(
- "insert into pack_heads(project_id, pack_key, current_version_id, updated_at) values (?, ?, ?, ?)",
- [projectId, "lane:lane-1", afterId, t1]
- );
-
- // Insert a legacy event without meta keys; getDeltaDigest should still be able to filter/select via injected meta.
- const eventId = randomUUID();
- db.run(
- "insert into pack_events(id, project_id, pack_key, event_type, payload_json, created_at) values (?, ?, ?, ?, ?, ?)",
- [eventId, projectId, "lane:lane-1", "refresh_triggered", JSON.stringify({ trigger: "session_end", laneId: "lane-1" }), "2026-02-15T19:05:00.000Z"]
- );
- for (let i = 0; i < 12; i += 1) {
- db.run(
- "insert into pack_events(id, project_id, pack_key, event_type, payload_json, created_at) values (?, ?, ?, ?, ?, ?)",
- [
- randomUUID(),
- projectId,
- "lane:lane-1",
- "refresh_triggered",
- JSON.stringify({ trigger: "session_end", laneId: "lane-1", i }),
- `2026-02-15T19:${String(6 + i).padStart(2, "0")}:00.000Z`
- ]
- );
- }
-
- const digest = await packService.getDeltaDigest({
- packKey: "lane:lane-1",
- sinceVersionId: beforeId,
- minimumImportance: "medium",
- limit: 10
- });
-
- expect(digest.newVersion.versionId).toBe(afterId);
- expect(digest.changedSections.some((c) => c.sectionId === "task_spec" && c.changeType === "modified")).toBe(true);
- expect(digest.highImpactEvents.length).toBeGreaterThan(0);
- expect((digest.highImpactEvents[0]!.payload as any).importance).toBeDefined();
- expect((digest.highImpactEvents[0]!.payload as any).category).toBeDefined();
- expect(digest.clipReason).toBe("budget_clipped");
- expect(digest.omittedSections).toContain("events:limit_cap");
- });
-});
diff --git a/apps/desktop/src/main/services/packs/packExports.test.ts b/apps/desktop/src/main/services/packs/packExports.test.ts
deleted file mode 100644
index bac1c234..00000000
--- a/apps/desktop/src/main/services/packs/packExports.test.ts
+++ /dev/null
@@ -1,325 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { ConflictLineageV1, LaneExportManifestV1, PackSummary } from "../../../shared/types";
-import {
- ADE_INTENT_END,
- ADE_INTENT_START,
- ADE_NARRATIVE_END,
- ADE_NARRATIVE_START,
- ADE_TASK_SPEC_END,
- ADE_TASK_SPEC_START,
- ADE_TODOS_END,
- ADE_TODOS_START
-} from "../../../shared/contextContract";
-import type { PackGraphEnvelopeV1 } from "../../../shared/contextContract";
-import { buildConflictExport, buildLaneExport } from "./packExports";
-
-function makeLanePackBody(args: { taskSpec: string; intent: string; todos?: string; narrative?: string }): string {
- return [
- "# Lane: Test Lane",
- "",
- "## Why",
- ADE_INTENT_START,
- args.intent,
- ADE_INTENT_END,
- "",
- "## Task Spec",
- ADE_TASK_SPEC_START,
- args.taskSpec,
- ADE_TASK_SPEC_END,
- "",
- "## What Changed",
- "- src/foo.ts (+10/-2)",
- "",
- "## Validation",
- "- Tests: PASS",
- "",
- "## Key Files",
- "| File | Change |",
- "|------|--------|",
- "| `src/foo.ts` | +10/-2 |",
- "",
- "## Errors & Issues",
- "- none",
- "",
- "## Sessions",
- "| When | Tool | Goal | Result | Delta |",
- "|------|------|------|--------|-------|",
- "| 12:00 | Shell | npm test | ok | +10/-2 |",
- "",
- "## Open Questions / Next Steps",
- "- ship it",
- "",
- "## Notes / Todos",
- ADE_TODOS_START,
- args.todos ?? "",
- ADE_TODOS_END,
- "",
- "## Narrative",
- ADE_NARRATIVE_START,
- args.narrative ?? "",
- ADE_NARRATIVE_END,
- "",
- "---",
- "*footer*",
- ""
- ].join("\n");
-}
-
-function makeLaneManifest(args: {
- projectId: string;
- laneId: string;
- laneName: string;
- branchRef: string;
- baseRef: string;
- headSha: string | null;
-}): LaneExportManifestV1 {
- return {
- schema: "ade.manifest.lane.v1",
- projectId: args.projectId,
- laneId: args.laneId,
- laneName: args.laneName,
- laneType: "worktree",
- worktreePath: "/tmp/worktree",
- branchRef: args.branchRef,
- baseRef: args.baseRef,
- lineage: {
- laneId: args.laneId,
- parentLaneId: null,
- baseLaneId: args.laneId,
- stackDepth: 0
- },
- mergeConstraints: {
- requiredMerges: [],
- blockedByLanes: [],
- mergeReadiness: "unknown"
- },
- branchState: {
- baseRef: args.baseRef,
- headRef: args.branchRef,
- headSha: args.headSha,
- lastPackRefreshAt: null,
- isEditProtected: false,
- packStale: null
- },
- conflicts: {
- activeConflictPackKeys: [],
- unresolvedPairCount: 0,
- lastConflictRefreshAt: null,
- lastConflictRefreshAgeMs: null
- }
- };
-}
-
-function parseHeaderFromExport(content: string): any {
- const start = content.indexOf("```json");
- if (start < 0) return null;
- const end = content.indexOf("```", start + "```json".length);
- if (end < 0) return null;
- const json = content.slice(start + "```json".length, end).trim();
- return JSON.parse(json);
-}
-
-describe("packExports", () => {
- it("buildLaneExport(lite) stays within budget and preserves required markers even with huge user sections", () => {
- const projectId = "proj-1";
- const huge = "x".repeat(20_000);
- const pack: PackSummary = {
- packKey: "lane:lane-1",
- packType: "lane",
- path: "/tmp/lane_pack.md",
- exists: true,
- deterministicUpdatedAt: "2026-02-14T00:00:00.000Z",
- narrativeUpdatedAt: "2026-02-14T00:00:00.000Z",
- lastHeadSha: "0123456789abcdef",
- versionId: "ver-1",
- versionNumber: 1,
- contentHash: "hash",
- metadata: null,
- body: makeLanePackBody({
- taskSpec: huge,
- intent: huge,
- todos: huge,
- narrative: huge
- })
- };
-
- const manifest = makeLaneManifest({
- projectId,
- laneId: "lane-1",
- laneName: "Test Lane",
- branchRef: "feature/test",
- baseRef: "main",
- headSha: pack.lastHeadSha ?? null
- });
- const graph: PackGraphEnvelopeV1 = {
- schema: "ade.packGraph.v1",
- relations: [
- {
- relationType: "depends_on",
- targetPackKey: "project",
- rationale: "test"
- }
- ]
- };
-
- const exp = buildLaneExport({
- level: "lite",
- projectId,
- laneId: "lane-1",
- laneName: "Test Lane",
- branchRef: "feature/test",
- baseRef: "main",
- headSha: pack.lastHeadSha,
- pack,
- providerMode: "guest",
- apiBaseUrl: null,
- remoteProjectId: null,
- graph,
- manifest,
- markers: {
- taskSpecStart: ADE_TASK_SPEC_START,
- taskSpecEnd: ADE_TASK_SPEC_END,
- intentStart: ADE_INTENT_START,
- intentEnd: ADE_INTENT_END,
- todosStart: ADE_TODOS_START,
- todosEnd: ADE_TODOS_END,
- narrativeStart: ADE_NARRATIVE_START,
- narrativeEnd: ADE_NARRATIVE_END
- },
- conflictRiskSummaryLines: ["- Conflict status: `unknown`"]
- });
-
- expect(exp.approxTokens).toBeLessThanOrEqual(exp.maxTokens);
- expect(exp.content).toContain("```json");
- expect(exp.content).toContain("## Task Spec");
- expect(exp.content).toContain(ADE_TASK_SPEC_START);
- expect(exp.content).toContain(ADE_TASK_SPEC_END);
- expect(exp.content).toContain("## Intent");
- expect(exp.content).toContain(ADE_INTENT_START);
- expect(exp.content).toContain(ADE_INTENT_END);
- expect(exp.content).toContain("## Conflict Risk Summary");
- expect(exp.content).toContain("...(truncated)...");
- expect(exp.content).toContain("## Manifest");
- expect(exp.clipReason).toBe("budget_clipped");
- expect((exp.omittedSections ?? []).length).toBeGreaterThan(0);
- const header = parseHeaderFromExport(exp.content);
- expect(header.projectId).toBe(projectId);
- expect(header.graph?.schema).toBe("ade.packGraph.v1");
- });
-
- it("buildLaneExport(deep) can include narrative markers, while standard omits narrative by default", () => {
- const projectId = "proj-1";
- const pack: PackSummary = {
- packKey: "lane:lane-2",
- packType: "lane",
- path: "/tmp/lane_pack.md",
- exists: true,
- deterministicUpdatedAt: "2026-02-14T00:00:00.000Z",
- narrativeUpdatedAt: "2026-02-14T00:00:00.000Z",
- lastHeadSha: "abcdef0123456789",
- body: makeLanePackBody({
- taskSpec: "- do the thing",
- intent: "because reasons",
- narrative: "narrative text"
- })
- };
-
- const commonArgs: Omit[0], "level"> = {
- projectId,
- laneId: "lane-2",
- laneName: "Test Lane 2",
- branchRef: "feature/test2",
- baseRef: "main",
- headSha: pack.lastHeadSha,
- pack,
- providerMode: "guest",
- apiBaseUrl: null,
- remoteProjectId: null,
- manifest: makeLaneManifest({
- projectId,
- laneId: "lane-2",
- laneName: "Test Lane 2",
- branchRef: "feature/test2",
- baseRef: "main",
- headSha: pack.lastHeadSha ?? null
- }),
- markers: {
- taskSpecStart: ADE_TASK_SPEC_START,
- taskSpecEnd: ADE_TASK_SPEC_END,
- intentStart: ADE_INTENT_START,
- intentEnd: ADE_INTENT_END,
- todosStart: ADE_TODOS_START,
- todosEnd: ADE_TODOS_END,
- narrativeStart: ADE_NARRATIVE_START,
- narrativeEnd: ADE_NARRATIVE_END
- },
- conflictRiskSummaryLines: [] as string[]
- };
-
- const standard = buildLaneExport({
- ...commonArgs,
- level: "standard"
- });
-
- expect(standard.approxTokens).toBeLessThanOrEqual(standard.maxTokens);
- expect(standard.content).not.toContain("## Narrative (Deep)");
- expect(standard.content).not.toContain(ADE_NARRATIVE_START);
-
- const deep = buildLaneExport({
- ...commonArgs,
- level: "deep"
- });
-
- expect(deep.approxTokens).toBeLessThanOrEqual(deep.maxTokens);
- expect(deep.content).toContain("## Narrative (Deep)");
- expect(deep.content).toContain(ADE_NARRATIVE_START);
- expect(deep.content).toContain(ADE_NARRATIVE_END);
- });
-
- it("buildConflictExport includes conflict lineage JSON when provided", () => {
- const projectId = "proj-1";
- const pack: PackSummary = {
- packKey: "conflict:lane-1:main",
- packType: "conflict",
- path: "/tmp/conflict_pack.md",
- exists: true,
- deterministicUpdatedAt: "2026-02-14T00:00:00.000Z",
- narrativeUpdatedAt: null,
- lastHeadSha: "abcdef0123456789",
- body: "# Conflict Pack\n\n## Overlapping Files\n- a\n"
- };
-
- const lineage: ConflictLineageV1 = {
- schema: "ade.conflictLineage.v1",
- laneId: "lane-1",
- peerKey: "main",
- predictionAt: null,
- lastRecomputedAt: null,
- truncated: null,
- strategy: null,
- pairwisePairsComputed: null,
- pairwisePairsTotal: null,
- stalePolicy: { ttlMs: 300000 },
- openConflictSummaries: [],
- unresolvedResolutionState: null
- };
-
- const exp = buildConflictExport({
- level: "standard",
- projectId,
- packKey: pack.packKey,
- laneId: "lane-1",
- peerLabel: "base:main",
- pack,
- providerMode: "guest",
- apiBaseUrl: null,
- remoteProjectId: null,
- lineage
- });
-
- expect(exp.content).toContain("## Conflict Lineage");
- expect(exp.content).toContain("\"schema\": \"ade.conflictLineage.v1\"");
- const header = parseHeaderFromExport(exp.content);
- expect(header.projectId).toBe(projectId);
- });
-});
diff --git a/apps/desktop/src/main/services/packs/packExports.ts b/apps/desktop/src/main/services/packs/packExports.ts
deleted file mode 100644
index 544cd686..00000000
--- a/apps/desktop/src/main/services/packs/packExports.ts
+++ /dev/null
@@ -1,665 +0,0 @@
-import type {
- ConflictLineageV1,
- ContextExportLevel,
- ContextHeaderV1,
- LaneExportManifestV1,
- PackConflictStateV1,
- PackDependencyStateV1,
- PackExport,
- PackSummary,
- PackType,
- ProjectExportManifestV1,
- ProviderMode
-} from "../../../shared/types";
-import { CONTEXT_CONTRACT_VERSION, CONTEXT_HEADER_SCHEMA_V1 } from "../../../shared/contextContract";
-import type { ExportOmissionV1, PackGraphEnvelopeV1 } from "../../../shared/contextContract";
-import { stripAnsi } from "../../utils/ansiStrip";
-import { extractBetweenMarkers, renderJsonSection } from "./packSections";
-
-type Budget = { maxTokens: number };
-
-const DEFAULT_BUDGETS: Record> = {
- project: {
- lite: { maxTokens: 900 },
- standard: { maxTokens: 2500 },
- deep: { maxTokens: 6500 }
- },
- lane: {
- lite: { maxTokens: 800 },
- standard: { maxTokens: 2800 },
- deep: { maxTokens: 8000 }
- },
- conflict: {
- lite: { maxTokens: 1100 },
- standard: { maxTokens: 3200 },
- deep: { maxTokens: 9000 }
- },
- feature: {
- lite: { maxTokens: 1000 },
- standard: { maxTokens: 2800 },
- deep: { maxTokens: 8000 }
- },
- plan: {
- lite: { maxTokens: 1100 },
- standard: { maxTokens: 3200 },
- deep: { maxTokens: 9000 }
- },
- mission: {
- lite: { maxTokens: 1200 },
- standard: { maxTokens: 3600 },
- deep: { maxTokens: 9000 }
- }
-};
-
-function approxTokensFromText(text: string): number {
- // Lightweight heuristic: ~4 characters/token for English+code.
- return Math.max(0, Math.ceil((text ?? "").length / 4));
-}
-
-function normalizeForExport(text: string): string {
- // Keep exports stable, human-readable, and safe to send over the wire.
- return stripAnsi(String(text ?? "")).replace(/\r\n/g, "\n");
-}
-
-function renderHeaderFence(header: ContextHeaderV1, opts: { pretty?: boolean } = {}): string {
- const pretty = opts.pretty !== false;
- return ["```json", pretty ? JSON.stringify(header, null, 2) : JSON.stringify(header), "```", ""].join("\n");
-}
-
-function ensureBudgetOmission(omissions: ExportOmissionV1[], truncated: boolean): ExportOmissionV1[] {
- if (!truncated) return omissions;
- if (omissions.some((o) => o.sectionId === "export" && o.reason === "budget_clipped")) return omissions;
- return [
- ...omissions,
- {
- sectionId: "export",
- reason: "budget_clipped",
- detail: "Export clipped to fit token budget.",
- recommendedLevel: "deep"
- }
- ];
-}
-
-function takeLines(lines: string[], max: number): { lines: string[]; truncated: boolean } {
- if (lines.length <= max) return { lines, truncated: false };
- return { lines: lines.slice(0, Math.max(0, max)), truncated: true };
-}
-
-function clipBlock(text: string, maxChars: number): { text: string; truncated: boolean } {
- const normalized = normalizeForExport(text ?? "").trim();
- if (maxChars <= 0) return { text: normalized, truncated: false };
- if (normalized.length <= maxChars) return { text: normalized, truncated: false };
- const clipped = `${normalized.slice(0, Math.max(0, maxChars - 20)).trimEnd()}\n...(truncated)...\n`;
- return { text: clipped, truncated: true };
-}
-
-function extractSectionLines(args: {
- content: string;
- headingPrefix: string;
- maxLines: number;
-}): { lines: string[]; truncated: boolean } {
- const raw = normalizeForExport(args.content);
- const lines = raw.split("\n");
-
- const startIdx = lines.findIndex((line) => line.trim() === args.headingPrefix || line.startsWith(args.headingPrefix));
- if (startIdx < 0) return { lines: [], truncated: false };
-
- const out: string[] = [];
- let inCodeFence = false;
- for (let i = startIdx + 1; i < lines.length; i++) {
- const line = lines[i] ?? "";
- const trimmed = line.trim();
- if (trimmed.startsWith("```")) inCodeFence = !inCodeFence;
- if (!inCodeFence && trimmed.startsWith("## ")) break;
- if (!inCodeFence && trimmed === "---") break;
- out.push(line);
- }
-
- // Drop leading/trailing whitespace-only lines to keep exports tidy.
- while (out.length && !out[0]!.trim()) out.shift();
- while (out.length && !out[out.length - 1]!.trim()) out.pop();
-
- return takeLines(out, args.maxLines);
-}
-
-function clipToBudget(args: { content: string; maxTokens: number }): { content: string; truncated: boolean } {
- const normalized = normalizeForExport(args.content);
- const approx = approxTokensFromText(normalized);
- if (approx <= args.maxTokens) return { content: normalized, truncated: false };
-
- const maxChars = Math.max(0, args.maxTokens * 4);
- if (normalized.length <= maxChars) return { content: normalized, truncated: false };
- const clipped = `${normalized.slice(0, Math.max(0, maxChars - 20)).trimEnd()}\n\n...(truncated)...\n`;
- return { content: clipped, truncated: true };
-}
-
-export function buildLaneExport(args: {
- level: ContextExportLevel;
- projectId: string | null;
- laneId: string;
- laneName: string;
- branchRef: string;
- baseRef: string;
- headSha: string | null;
- pack: PackSummary;
- providerMode: ProviderMode;
- apiBaseUrl: string | null;
- remoteProjectId: string | null;
- graph?: PackGraphEnvelopeV1 | null;
- manifest?: LaneExportManifestV1 | null;
- dependencyState?: PackDependencyStateV1 | null;
- conflictState?: PackConflictStateV1 | null;
- markers: {
- taskSpecStart: string;
- taskSpecEnd: string;
- intentStart: string;
- intentEnd: string;
- todosStart: string;
- todosEnd: string;
- narrativeStart: string;
- narrativeEnd: string;
- };
- conflictRiskSummaryLines: string[];
-}): PackExport {
- const level = args.level;
- const budget = DEFAULT_BUDGETS.lane[level];
-
- const body = normalizeForExport(args.pack.body ?? "");
-
- const taskSpecRaw =
- extractBetweenMarkers(body, args.markers.taskSpecStart, args.markers.taskSpecEnd) ??
- "(task spec missing; lane context markers unavailable)";
- const intentRaw =
- extractBetweenMarkers(body, args.markers.intentStart, args.markers.intentEnd) ??
- "(intent missing; lane context markers unavailable)";
- const todosRaw = extractBetweenMarkers(body, args.markers.todosStart, args.markers.todosEnd) ?? "";
- const narrativeRaw = extractBetweenMarkers(body, args.markers.narrativeStart, args.markers.narrativeEnd) ?? "";
-
- const whatChanged = extractSectionLines({
- content: body,
- headingPrefix: "## What Changed",
- maxLines: level === "lite" ? 10 : level === "standard" ? 24 : 80
- });
- const validation = extractSectionLines({
- content: body,
- headingPrefix: "## Validation",
- maxLines: level === "lite" ? 8 : level === "standard" ? 16 : 40
- });
- const keyFiles = extractSectionLines({
- content: body,
- headingPrefix: "## Key Files",
- maxLines: level === "lite" ? 10 : level === "standard" ? 20 : 60
- });
- const errors = extractSectionLines({
- content: body,
- headingPrefix: "## Errors & Issues",
- maxLines: level === "lite" ? 12 : level === "standard" ? 30 : 120
- });
- const sessions = extractSectionLines({
- content: body,
- headingPrefix: "## Sessions",
- maxLines: level === "lite" ? 12 : level === "standard" ? 24 : 80
- });
- const nextSteps = extractSectionLines({
- content: body,
- headingPrefix: "## Open Questions / Next Steps",
- maxLines: level === "lite" ? 16 : level === "standard" ? 40 : 120
- });
-
- const warnings: string[] = [];
- const omissionsBase: ExportOmissionV1[] = [];
- const userBlockLimits =
- level === "lite"
- ? { taskSpecChars: 650, intentChars: 360, todosChars: 450, narrativeChars: 0 }
- : level === "standard"
- ? { taskSpecChars: 2200, intentChars: 1400, todosChars: 1200, narrativeChars: 0 }
- : { taskSpecChars: 4000, intentChars: 2200, todosChars: 2000, narrativeChars: 5000 };
-
- const taskSpec = clipBlock(taskSpecRaw, userBlockLimits.taskSpecChars);
- const intent = clipBlock(intentRaw, userBlockLimits.intentChars);
- const todos = clipBlock(todosRaw, userBlockLimits.todosChars);
- const narrative = clipBlock(narrativeRaw, userBlockLimits.narrativeChars);
-
- if (taskSpec.truncated) {
- warnings.push("Task Spec section truncated for export budget.");
- omissionsBase.push({ sectionId: "task_spec", reason: "truncated_section", detail: "Task Spec truncated." });
- }
- if (intent.truncated) {
- warnings.push("Intent section truncated for export budget.");
- omissionsBase.push({ sectionId: "intent", reason: "truncated_section", detail: "Intent truncated." });
- }
- if (todos.truncated) {
- warnings.push("Todos section truncated for export budget.");
- omissionsBase.push({ sectionId: "todos", reason: "truncated_section", detail: "Todos truncated." });
- }
- if (narrative.truncated) {
- warnings.push("Narrative section truncated for export budget.");
- omissionsBase.push({ sectionId: "narrative", reason: "truncated_section", detail: "Narrative truncated." });
- }
- if (whatChanged.truncated) {
- warnings.push("What Changed section truncated for export budget.");
- omissionsBase.push({ sectionId: "what_changed", reason: "truncated_section", detail: "What Changed truncated." });
- }
- if (validation.truncated) {
- warnings.push("Validation section truncated for export budget.");
- omissionsBase.push({ sectionId: "validation", reason: "truncated_section", detail: "Validation truncated." });
- }
- if (keyFiles.truncated) {
- warnings.push("Key Files section truncated for export budget.");
- omissionsBase.push({ sectionId: "key_files", reason: "truncated_section", detail: "Key Files truncated." });
- }
- if (errors.truncated) {
- warnings.push("Errors section truncated for export budget.");
- omissionsBase.push({ sectionId: "errors", reason: "truncated_section", detail: "Errors truncated." });
- }
- if (sessions.truncated) {
- warnings.push("Sessions section truncated for export budget.");
- omissionsBase.push({ sectionId: "sessions", reason: "truncated_section", detail: "Sessions truncated." });
- }
- if (nextSteps.truncated) {
- warnings.push("Next Steps section truncated for export budget.");
- omissionsBase.push({ sectionId: "next_steps", reason: "truncated_section", detail: "Next Steps truncated." });
- }
-
- const exportedAt = new Date().toISOString();
- const header: ContextHeaderV1 = {
- schema: CONTEXT_HEADER_SCHEMA_V1,
- contractVersion: CONTEXT_CONTRACT_VERSION,
- projectId: args.projectId,
- packKey: args.pack.packKey,
- packType: "lane",
- exportLevel: level,
- laneId: args.laneId,
- peerKey: null,
- baseRef: args.baseRef,
- headSha: args.headSha,
- deterministicUpdatedAt: args.pack.deterministicUpdatedAt,
- narrativeUpdatedAt: args.pack.narrativeUpdatedAt,
- versionId: args.pack.versionId ?? null,
- versionNumber: args.pack.versionNumber ?? null,
- contentHash: args.pack.contentHash ?? null,
- providerMode: args.providerMode,
- exportedAt,
- apiBaseUrl: args.apiBaseUrl,
- remoteProjectId: args.remoteProjectId,
- graph: args.graph ?? null,
- dependencyState: args.dependencyState ?? null,
- conflictState: args.conflictState ?? null,
- omissions: null
- };
-
- const lines: string[] = [];
- lines.push(`# Lane Export (${level.toUpperCase()})`);
- lines.push(
- `> Lane: ${normalizeForExport(args.laneName)} | Branch: \`${normalizeForExport(args.branchRef)}\` | Base: \`${normalizeForExport(args.baseRef)}\``
- );
- lines.push("");
-
- if (args.manifest) {
- const liteManifest =
- level === "lite"
- ? {
- schema: args.manifest.schema,
- projectId: args.manifest.projectId,
- laneId: args.manifest.laneId,
- laneName: args.manifest.laneName,
- laneType: args.manifest.laneType,
- branchRef: args.manifest.branchRef,
- baseRef: args.manifest.baseRef,
- lineage: args.manifest.lineage,
- mergeConstraints: args.manifest.mergeConstraints,
- branchState: {
- baseRef: args.manifest.branchState?.baseRef ?? null,
- headRef: args.manifest.branchState?.headRef ?? null,
- headSha: args.manifest.branchState?.headSha ?? null,
- lastPackRefreshAt: args.manifest.branchState?.lastPackRefreshAt ?? null,
- isEditProtected: args.manifest.branchState?.isEditProtected ?? null,
- packStale: args.manifest.branchState?.packStale ?? null,
- ...(args.manifest.branchState?.packStaleReason ? { packStaleReason: args.manifest.branchState.packStaleReason } : {})
- },
- conflicts: {
- activeConflictPackKeys: args.manifest.conflicts?.activeConflictPackKeys ?? [],
- unresolvedPairCount: args.manifest.conflicts?.unresolvedPairCount ?? 0,
- lastConflictRefreshAt: args.manifest.conflicts?.lastConflictRefreshAt ?? null,
- lastConflictRefreshAgeMs: args.manifest.conflicts?.lastConflictRefreshAgeMs ?? null,
- ...(args.manifest.conflicts?.predictionStale != null ? { predictionStale: args.manifest.conflicts.predictionStale } : {}),
- ...(args.manifest.conflicts?.stalePolicy ? { stalePolicy: args.manifest.conflicts.stalePolicy } : {}),
- ...(args.manifest.conflicts?.staleReason ? { staleReason: args.manifest.conflicts.staleReason } : {})
- }
- }
- : args.manifest;
-
- lines.push(...renderJsonSection("## Manifest", liteManifest, { pretty: level !== "lite" }));
- } else {
- lines.push(...renderJsonSection("## Manifest", { schema: "ade.manifest.lane.v1", unavailable: true }, { pretty: level !== "lite" }));
- omissionsBase.push({ sectionId: "manifest", reason: "data_unavailable", detail: "Manifest unavailable." });
- }
-
- lines.push("## Task Spec");
- lines.push(args.markers.taskSpecStart);
- lines.push(taskSpec.text);
- lines.push(args.markers.taskSpecEnd);
- lines.push("");
-
- lines.push("## Intent");
- lines.push(args.markers.intentStart);
- lines.push(intent.text);
- lines.push(args.markers.intentEnd);
- lines.push("");
-
- lines.push("## Conflict Risk Summary");
- if (args.conflictRiskSummaryLines.length) {
- const max = level === "lite" ? 8 : args.conflictRiskSummaryLines.length;
- for (const line of args.conflictRiskSummaryLines.slice(0, max)) lines.push(line);
- } else {
- lines.push("- Conflict status: unknown (prediction not available yet)");
- }
- lines.push("");
-
- if (whatChanged.lines.length) {
- lines.push("## What Changed");
- lines.push(...whatChanged.lines);
- lines.push("");
- }
-
- if (validation.lines.length) {
- lines.push("## Validation");
- lines.push(...validation.lines);
- lines.push("");
- }
-
- if (keyFiles.lines.length) {
- lines.push("## Key Files");
- lines.push(...keyFiles.lines);
- lines.push("");
- }
-
- if (errors.lines.length) {
- lines.push("## Errors & Issues");
- lines.push(...errors.lines);
- lines.push("");
- }
-
- if (sessions.lines.length) {
- lines.push("## Sessions");
- lines.push(...sessions.lines);
- lines.push("");
- }
-
- if (nextSteps.lines.length) {
- lines.push("## Next Steps");
- lines.push(...nextSteps.lines);
- lines.push("");
- }
-
- if (todos.text.trim().length) {
- lines.push("## Notes / Todos");
- lines.push(args.markers.todosStart);
- lines.push(todos.text);
- lines.push(args.markers.todosEnd);
- lines.push("");
- }
-
- if (level === "deep" && narrative.text.trim().length) {
- lines.push("## Narrative (Deep)");
- lines.push(args.markers.narrativeStart);
- lines.push(narrative.text);
- lines.push(args.markers.narrativeEnd);
- lines.push("");
- } else if (level !== "deep") {
- omissionsBase.push({
- sectionId: "narrative",
- reason: "omitted_by_level",
- detail: "Narrative is only included at deep export level.",
- recommendedLevel: "deep"
- });
- }
-
- const buildContent = (omissions: ExportOmissionV1[]) => {
- header.omissions = omissions.length ? omissions : null;
- header.maxTokens = budget.maxTokens;
- const draft = `${renderHeaderFence(header, { pretty: level !== "lite" })}${lines.join("\n")}\n`;
- return clipToBudget({ content: draft, maxTokens: budget.maxTokens });
- };
-
- let clipped = buildContent(omissionsBase);
- const omissionsFinal = ensureBudgetOmission(omissionsBase, clipped.truncated);
- if (omissionsFinal !== omissionsBase) {
- clipped = buildContent(omissionsFinal);
- }
-
- const approxTokens = approxTokensFromText(clipped.content);
- header.approxTokens = approxTokens;
-
- return {
- packKey: args.pack.packKey,
- packType: "lane",
- level,
- header,
- content: clipped.content,
- approxTokens,
- maxTokens: budget.maxTokens,
- truncated: clipped.truncated,
- warnings: clipped.truncated ? [...warnings, "Export clipped to fit token budget."] : warnings,
- clipReason: clipped.truncated ? "budget_clipped" : null,
- omittedSections: (header.omissions ?? []).map((entry) => entry.sectionId)
- };
-}
-
-export function buildProjectExport(args: {
- level: ContextExportLevel;
- projectId: string | null;
- pack: PackSummary;
- providerMode: ProviderMode;
- apiBaseUrl: string | null;
- remoteProjectId: string | null;
- graph?: PackGraphEnvelopeV1 | null;
- manifest?: ProjectExportManifestV1 | null;
-}): PackExport {
- const level = args.level;
- const budget = DEFAULT_BUDGETS.project[level];
- const body = normalizeForExport(args.pack.body ?? "");
-
- const overview = extractSectionLines({
- content: body,
- headingPrefix: "# Project Pack",
- maxLines: level === "lite" ? 60 : level === "standard" ? 140 : 400
- });
-
- const warnings: string[] = [];
- const omissionsBase: ExportOmissionV1[] = [];
-
- const exportedAt = new Date().toISOString();
- const header: ContextHeaderV1 = {
- schema: CONTEXT_HEADER_SCHEMA_V1,
- contractVersion: CONTEXT_CONTRACT_VERSION,
- projectId: args.projectId,
- packKey: args.pack.packKey,
- packType: "project",
- exportLevel: level,
- laneId: null,
- peerKey: null,
- baseRef: null,
- headSha: null,
- deterministicUpdatedAt: args.pack.deterministicUpdatedAt,
- narrativeUpdatedAt: args.pack.narrativeUpdatedAt,
- versionId: args.pack.versionId ?? null,
- versionNumber: args.pack.versionNumber ?? null,
- contentHash: args.pack.contentHash ?? null,
- providerMode: args.providerMode,
- exportedAt,
- apiBaseUrl: args.apiBaseUrl,
- remoteProjectId: args.remoteProjectId,
- graph: args.graph ?? null,
- omissions: null
- };
-
- const lines: string[] = [];
- lines.push(`# Project Export (${level.toUpperCase()})`);
- lines.push("");
-
- if (args.manifest) {
- lines.push(...renderJsonSection("## Manifest", args.manifest, { pretty: level !== "lite" }));
- } else {
- lines.push(...renderJsonSection("## Manifest", { schema: "ade.manifest.project.v1", unavailable: true }, { pretty: level !== "lite" }));
- omissionsBase.push({ sectionId: "manifest", reason: "data_unavailable", detail: "Manifest unavailable." });
- }
-
- if (overview.lines.length) {
- lines.push("## Snapshot");
- lines.push(...overview.lines);
- lines.push("");
- } else {
- lines.push("## Snapshot");
- lines.push("- Project context is currently unavailable.");
- lines.push("");
- omissionsBase.push({ sectionId: "snapshot", reason: "data_unavailable", detail: "Snapshot unavailable." });
- }
-
- if (overview.truncated) {
- warnings.push("Project snapshot truncated for export budget.");
- omissionsBase.push({ sectionId: "snapshot", reason: "truncated_section", detail: "Snapshot truncated." });
- }
-
- const buildContent = (omissions: ExportOmissionV1[]) => {
- header.omissions = omissions.length ? omissions : null;
- header.maxTokens = budget.maxTokens;
- const draft = `${renderHeaderFence(header, { pretty: level !== "lite" })}${lines.join("\n")}\n`;
- return clipToBudget({ content: draft, maxTokens: budget.maxTokens });
- };
-
- let clipped = buildContent(omissionsBase);
- const omissionsFinal = ensureBudgetOmission(omissionsBase, clipped.truncated);
- if (omissionsFinal !== omissionsBase) {
- clipped = buildContent(omissionsFinal);
- }
-
- const approxTokens = approxTokensFromText(clipped.content);
- header.approxTokens = approxTokens;
-
- return {
- packKey: args.pack.packKey,
- packType: "project",
- level,
- header,
- content: clipped.content,
- approxTokens,
- maxTokens: budget.maxTokens,
- truncated: clipped.truncated,
- warnings: clipped.truncated ? [...warnings, "Export clipped to fit token budget."] : warnings,
- clipReason: clipped.truncated ? "budget_clipped" : null,
- omittedSections: (header.omissions ?? []).map((entry) => entry.sectionId)
- };
-}
-
-export function buildConflictExport(args: {
- level: ContextExportLevel;
- projectId: string | null;
- packKey: string;
- laneId: string;
- peerLabel: string;
- pack: PackSummary;
- providerMode: ProviderMode;
- apiBaseUrl: string | null;
- remoteProjectId: string | null;
- graph?: PackGraphEnvelopeV1 | null;
- lineage?: ConflictLineageV1 | null;
-}): PackExport {
- const level = args.level;
- const budget = DEFAULT_BUDGETS.conflict[level];
- const body = normalizeForExport(args.pack.body ?? "");
-
- const overlapLines = extractSectionLines({
- content: body,
- headingPrefix: "## Overlapping Files",
- maxLines: level === "lite" ? 24 : level === "standard" ? 60 : 220
- });
- const conflictsLines = extractSectionLines({
- content: body,
- headingPrefix: "## Conflicts (merge-tree)",
- maxLines: level === "lite" ? 60 : level === "standard" ? 140 : 400
- });
-
- const warnings: string[] = [];
- const omissionsBase: ExportOmissionV1[] = [];
- if (overlapLines.truncated) warnings.push("Overlap list truncated for export budget.");
- if (conflictsLines.truncated) warnings.push("Conflicts section truncated for export budget.");
- if (overlapLines.truncated) omissionsBase.push({ sectionId: "overlap_files", reason: "truncated_section", detail: "Overlap list truncated." });
- if (conflictsLines.truncated) omissionsBase.push({ sectionId: "merge_tree", reason: "truncated_section", detail: "Merge-tree conflicts truncated." });
-
- const exportedAt = new Date().toISOString();
- const header: ContextHeaderV1 = {
- schema: CONTEXT_HEADER_SCHEMA_V1,
- contractVersion: CONTEXT_CONTRACT_VERSION,
- projectId: args.projectId,
- packKey: args.packKey,
- packType: "conflict",
- exportLevel: level,
- laneId: args.laneId,
- peerKey: args.peerLabel,
- baseRef: null,
- headSha: args.pack.lastHeadSha ?? null,
- deterministicUpdatedAt: args.pack.deterministicUpdatedAt,
- narrativeUpdatedAt: args.pack.narrativeUpdatedAt,
- versionId: args.pack.versionId ?? null,
- versionNumber: args.pack.versionNumber ?? null,
- contentHash: args.pack.contentHash ?? null,
- providerMode: args.providerMode,
- exportedAt,
- apiBaseUrl: args.apiBaseUrl,
- remoteProjectId: args.remoteProjectId,
- graph: args.graph ?? null,
- omissions: null
- };
-
- const lines: string[] = [];
- lines.push(`# Conflict Export (${level.toUpperCase()})`);
- lines.push(`> Lane: ${args.laneId} | Peer: ${normalizeForExport(args.peerLabel)}`);
- lines.push("");
-
- if (args.lineage) {
- lines.push(...renderJsonSection("## Conflict Lineage", args.lineage, { pretty: level !== "lite" }));
- } else {
- omissionsBase.push({ sectionId: "conflict_lineage", reason: "data_unavailable", detail: "Conflict lineage unavailable." });
- }
-
- lines.push("## Overlapping Files");
- if (overlapLines.lines.length) lines.push(...overlapLines.lines);
- else lines.push("- (none listed; live conflict context is unavailable)");
- lines.push("");
-
- lines.push("## Conflicts (merge-tree)");
- if (conflictsLines.lines.length) lines.push(...conflictsLines.lines);
- else lines.push("- (none listed; live conflict context is unavailable)");
- lines.push("");
-
- const buildContent = (omissions: ExportOmissionV1[]) => {
- header.omissions = omissions.length ? omissions : null;
- header.maxTokens = budget.maxTokens;
- const draft = `${renderHeaderFence(header, { pretty: level !== "lite" })}${lines.join("\n")}\n`;
- return clipToBudget({ content: draft, maxTokens: budget.maxTokens });
- };
-
- let clipped = buildContent(omissionsBase);
- const omissionsFinal = ensureBudgetOmission(omissionsBase, clipped.truncated);
- if (omissionsFinal !== omissionsBase) {
- clipped = buildContent(omissionsFinal);
- }
-
- const approxTokens = approxTokensFromText(clipped.content);
- header.approxTokens = approxTokens;
-
- return {
- packKey: args.packKey,
- packType: "conflict",
- level,
- header,
- content: clipped.content,
- approxTokens,
- maxTokens: budget.maxTokens,
- truncated: clipped.truncated,
- warnings: clipped.truncated ? [...warnings, "Export clipped to fit token budget."] : warnings,
- clipReason: clipped.truncated ? "budget_clipped" : null,
- omittedSections: (header.omissions ?? []).map((entry) => entry.sectionId)
- };
-}
diff --git a/apps/desktop/src/main/services/packs/packSections.test.ts b/apps/desktop/src/main/services/packs/packSections.test.ts
deleted file mode 100644
index 0f0b359d..00000000
--- a/apps/desktop/src/main/services/packs/packSections.test.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { computeSectionChanges, replaceBetweenMarkers, upsertSectionByHeading } from "./packSections";
-import type { SectionLocator } from "./packSections";
-
-const N_START = "";
-const N_END = "";
-
-describe("packSections", () => {
- it("replaces content between markers without touching surrounding content", () => {
- const input = [
- "# Lane: demo",
- "",
- "## Narrative",
- N_START,
- "old narrative",
- N_END,
- "",
- "---",
- "*Updated: 2026-02-14T00:00:00Z*",
- ""
- ].join("\n");
-
- const out = replaceBetweenMarkers({
- content: input,
- startMarker: N_START,
- endMarker: N_END,
- body: "new narrative"
- }).content;
-
- expect(out).toContain(N_START);
- expect(out).toContain("new narrative");
- expect(out).toContain(N_END);
- expect(out).toContain("*Updated: 2026-02-14T00:00:00Z*");
- expect(out).not.toContain("old narrative");
- });
-
- it("upserts narrative markers into older packs and preserves footer after Narrative", () => {
- const input = [
- "# Lane: demo",
- "",
- "## Narrative",
- "old narrative",
- "",
- "---",
- "*Updated: 2026-02-14T00:00:00Z*",
- ""
- ].join("\n");
-
- const { content: out, insertedMarkers } = upsertSectionByHeading({
- content: input,
- heading: "## Narrative",
- startMarker: N_START,
- endMarker: N_END,
- body: "new narrative"
- });
-
- expect(insertedMarkers).toBe(true);
- expect(out).toContain("## Narrative");
- expect(out).toContain(N_START);
- expect(out).toContain("new narrative");
- expect(out).toContain(N_END);
- expect(out).toContain("---");
- expect(out).toContain("*Updated: 2026-02-14T00:00:00Z*");
- expect(out).not.toContain("old narrative");
- });
-
- it("is idempotent when markers already exist", () => {
- const input = [
- "# Lane: demo",
- "",
- "## Narrative",
- N_START,
- "new narrative",
- N_END,
- "",
- "## Other Section",
- "keep me",
- ""
- ].join("\n");
-
- const first = upsertSectionByHeading({
- content: input,
- heading: "## Narrative",
- startMarker: N_START,
- endMarker: N_END,
- body: "new narrative"
- });
- const second = upsertSectionByHeading({
- content: first.content,
- heading: "## Narrative",
- startMarker: N_START,
- endMarker: N_END,
- body: "new narrative"
- });
-
- expect(first.insertedMarkers).toBe(false);
- expect(second.insertedMarkers).toBe(false);
- expect(second.content).toBe(first.content);
- expect(second.content).toContain("## Other Section");
- expect(second.content).toContain("keep me");
- });
-
- it("computeSectionChanges is deterministic and null-safe", () => {
- const locators: SectionLocator[] = [
- { id: "narrative", kind: "markers", startMarker: N_START, endMarker: N_END },
- { id: "other", kind: "heading", heading: "## Other Section" }
- ];
-
- const before = [
- "# Lane: demo",
- "",
- "## Narrative",
- N_START,
- "old",
- N_END,
- "",
- "## Other Section",
- "keep",
- ""
- ].join("\n");
-
- const after = [
- "# Lane: demo",
- "",
- "## Narrative",
- N_START,
- "new",
- N_END,
- "",
- "## Other Section",
- "keep",
- ""
- ].join("\n");
-
- const first = computeSectionChanges({ before, after, locators });
- const second = computeSectionChanges({ before, after, locators });
- expect(first).toEqual(second);
- expect(first.some((c) => c.sectionId === "narrative" && c.changeType === "modified")).toBe(true);
-
- const nullSafe = computeSectionChanges({ before: null, after, locators });
- expect(nullSafe.some((c) => c.sectionId === "narrative")).toBe(true);
- });
-});
diff --git a/apps/desktop/src/main/services/packs/packSections.ts b/apps/desktop/src/main/services/packs/packSections.ts
deleted file mode 100644
index c8f03535..00000000
--- a/apps/desktop/src/main/services/packs/packSections.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-type UpsertByHeadingArgs = {
- content: string;
- heading: string; // e.g. "## Narrative"
- startMarker: string;
- endMarker: string;
- body: string;
-};
-
-export type SectionLocator =
- | { id: string; kind: "markers"; startMarker: string; endMarker: string }
- | { id: string; kind: "heading"; heading: string };
-
-function escapeRegExp(value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
-}
-
-function findLineEnd(content: string, fromIndex: number): number {
- const idx = content.indexOf("\n", fromIndex);
- return idx >= 0 ? idx + 1 : content.length;
-}
-
-function findNextIndex(content: string, regex: RegExp, fromIndex: number): number {
- let flags = regex.flags;
- if (!flags.includes("g")) flags += "g";
- if (!flags.includes("m")) flags += "m";
- const re = new RegExp(regex.source, flags);
- re.lastIndex = Math.max(0, fromIndex);
- const match = re.exec(content);
- return match ? match.index : -1;
-}
-
-export function extractBetweenMarkers(content: string, startMarker: string, endMarker: string): string | null {
- const startIdx = content.indexOf(startMarker);
- const endIdx = content.indexOf(endMarker);
- if (startIdx < 0 || endIdx < 0 || endIdx <= startIdx) return null;
- const body = content.slice(startIdx + startMarker.length, endIdx).trim();
- return body.length ? body : "";
-}
-
-export function replaceBetweenMarkers(args: {
- content: string;
- startMarker: string;
- endMarker: string;
- body: string;
-}): { content: string; changed: boolean } {
- const startIdx = args.content.indexOf(args.startMarker);
- const endIdx = args.content.indexOf(args.endMarker);
- if (startIdx < 0 || endIdx < 0 || endIdx <= startIdx) {
- return { content: args.content, changed: false };
- }
-
- const before = args.content.slice(0, startIdx + args.startMarker.length);
- const after = args.content.slice(endIdx);
- const nextBody = args.body.trim();
- const updated = `${before}\n${nextBody}\n${after}`;
- return { content: updated, changed: updated !== args.content };
-}
-
-export function upsertSectionByHeading(args: UpsertByHeadingArgs): { content: string; insertedMarkers: boolean } {
- // First preference: marker-based replacement.
- const replaced = replaceBetweenMarkers({
- content: args.content,
- startMarker: args.startMarker,
- endMarker: args.endMarker,
- body: args.body
- });
- if (replaced.changed || (args.content.includes(args.startMarker) && args.content.includes(args.endMarker))) {
- return { content: replaced.content, insertedMarkers: false };
- }
-
- // Upgrade older packs: insert markers inside an existing heading section.
- const headingRe = new RegExp(`^${escapeRegExp(args.heading)}\\s*$`, "m");
- const match = headingRe.exec(args.content);
- if (match?.index != null) {
- const headingStart = match.index;
- const headingLineEnd = findLineEnd(args.content, headingStart);
-
- const nextHeadingIdx = findNextIndex(args.content, /^##\s+/gm, headingLineEnd);
- const nextHrIdx = findNextIndex(args.content, /^---\s*$/gm, headingLineEnd);
-
- const candidates = [nextHeadingIdx, nextHrIdx].filter((idx) => idx >= 0);
- const sectionEnd = candidates.length ? Math.min(...candidates) : args.content.length;
-
- const before = args.content.slice(0, headingLineEnd);
- const after = args.content.slice(sectionEnd);
- const body = args.body.trim();
- const updated = `${before}${args.startMarker}\n${body}\n${args.endMarker}\n${after}`;
- return { content: updated, insertedMarkers: true };
- }
-
- // If the heading doesn't exist, append a new section at the end.
- const trimmed = args.content.trimEnd();
- const body = args.body.trim();
- const suffix = `${args.heading}\n${args.startMarker}\n${body}\n${args.endMarker}\n`;
- const updated = trimmed.length ? `${trimmed}\n\n${suffix}` : `${suffix}`;
- return { content: updated, insertedMarkers: true };
-}
-
-export function extractSectionByHeading(content: string, heading: string): string | null {
- const headingRe = new RegExp(`^${escapeRegExp(heading)}\\s*$`, "m");
- const match = headingRe.exec(content);
- if (!match?.index && match?.index !== 0) return null;
-
- const headingStart = match.index;
- const headingLineEnd = findLineEnd(content, headingStart);
-
- const nextHeadingIdx = findNextIndex(content, /^##\s+/gm, headingLineEnd);
- const nextHrIdx = findNextIndex(content, /^---\s*$/gm, headingLineEnd);
- const candidates = [nextHeadingIdx, nextHrIdx].filter((idx) => idx >= 0);
- const sectionEnd = candidates.length ? Math.min(...candidates) : content.length;
-
- const body = content.slice(headingLineEnd, sectionEnd).trim();
- return body.length ? body : "";
-}
-
-export function extractSectionContent(content: string, locator: SectionLocator): string | null {
- if (locator.kind === "markers") return extractBetweenMarkers(content, locator.startMarker, locator.endMarker);
- if (locator.kind === "heading") return extractSectionByHeading(content, locator.heading);
- return null;
-}
-
-export function computeSectionChanges(args: {
- before: string | null;
- after: string;
- locators: SectionLocator[];
-}): Array<{ sectionId: string; changeType: "added" | "removed" | "modified" }> {
- const norm = (value: string | null): string | null => {
- if (value == null) return null;
- return String(value).replace(/\r\n/g, "\n").trim();
- };
-
- const beforeContent = args.before ?? "";
- const out: Array<{ sectionId: string; changeType: "added" | "removed" | "modified" }> = [];
-
- for (const locator of args.locators) {
- const a = norm(extractSectionContent(beforeContent, locator));
- const b = norm(extractSectionContent(args.after, locator));
-
- if (a == null && b == null) continue;
- if (a == null && b != null) {
- out.push({ sectionId: locator.id, changeType: "added" });
- continue;
- }
- if (a != null && b == null) {
- out.push({ sectionId: locator.id, changeType: "removed" });
- continue;
- }
- if (a !== b) out.push({ sectionId: locator.id, changeType: "modified" });
- }
-
- return out;
-}
-
-export function renderJsonSection(heading: string, value: unknown, opts: { pretty?: boolean } = {}): string[] {
- const pretty = opts.pretty !== false;
- let json = "";
- try {
- json = pretty ? JSON.stringify(value ?? null, null, 2) : JSON.stringify(value ?? null);
- } catch {
- json = pretty
- ? JSON.stringify({ error: "Failed to serialize JSON section." }, null, 2)
- : JSON.stringify({ error: "Failed to serialize JSON section." });
- }
- return [heading, "```json", json, "```", ""];
-}
diff --git a/apps/desktop/src/main/services/packs/packService.docsFreshness.test.ts b/apps/desktop/src/main/services/packs/packService.docsFreshness.test.ts
deleted file mode 100644
index 1ce30a25..00000000
--- a/apps/desktop/src/main/services/packs/packService.docsFreshness.test.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { describe, expect, it } from "vitest";
-import fs from "node:fs";
-import os from "node:os";
-import path from "node:path";
-import { spawnSync } from "node:child_process";
-import { openKvDb } from "../state/kvDb";
-import { createPackService } from "./packService";
-
-function git(cwd: string, args: string[]): string {
- const res = spawnSync("git", args, { cwd, encoding: "utf8" });
- if (res.status !== 0) {
- throw new Error(`git ${args.join(" ")} failed: ${res.stderr || res.stdout}`);
- }
- return (res.stdout ?? "").trim();
-}
-
-function createLogger() {
- return {
- debug: () => {},
- info: () => {},
- warn: () => {},
- error: () => {}
- } as any;
-}
-
-function readContextFingerprint(body: string): string {
- const line = body
- .split(/\r?\n/)
- .find((entry) => entry.startsWith("Context fingerprint:"));
- const value = (line ?? "").split(":").slice(1).join(":").trim();
- if (!value) throw new Error("Context fingerprint line missing");
- return value;
-}
-
-describe("packService docs freshness", () => {
- it("refreshes project context fingerprint when docs change", async () => {
- const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pack-docs-"));
- const packsDir = path.join(projectRoot, ".ade", "packs");
- fs.mkdirSync(path.join(projectRoot, "docs", "architecture"), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, "docs", "features"), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, "docs", "PRD.md"), "# PRD\n\nInitial\n", "utf8");
- fs.writeFileSync(path.join(projectRoot, "docs", "architecture", "SYSTEM.md"), "# System\n\nv1\n", "utf8");
- fs.writeFileSync(path.join(projectRoot, "docs", "features", "CONFLICTS.md"), "# Conflicts\n\nv1\n", "utf8");
- git(projectRoot, ["init", "-b", "main"]);
- git(projectRoot, ["config", "user.email", "ade@test.local"]);
- git(projectRoot, ["config", "user.name", "ADE Test"]);
- git(projectRoot, ["add", "."]);
- git(projectRoot, ["commit", "-m", "init docs"]);
-
- const db = await openKvDb(path.join(projectRoot, "kv.sqlite"), createLogger());
- const projectId = "proj-docs";
- const now = "2026-02-15T19:00:00.000Z";
- db.run(
- "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)",
- [projectId, projectRoot, "demo", "main", now, now]
- );
-
- const packService = createPackService({
- db,
- logger: createLogger(),
- projectRoot,
- projectId,
- packsDir,
- laneService: {
- list: async () => [],
- getLaneBaseAndBranch: () => ({ worktreePath: projectRoot, baseRef: "main", branchRef: "main" })
- } as any,
- sessionService: { readTranscriptTail: () => "" } as any,
- projectConfigService: {
- get: () => ({
- local: { providers: {} },
- effective: {
- providerMode: "guest",
- providers: {},
- processes: [],
- testSuites: [],
- stackButtons: []
- }
- })
- } as any,
- operationService: { start: () => ({ operationId: "op-1" }), finish: () => {} } as any
- });
-
- const first = await packService.refreshProjectPack({ reason: "onboarding_init" });
- const firstFingerprint = readContextFingerprint(first.body);
-
- fs.writeFileSync(path.join(projectRoot, "docs", "architecture", "SYSTEM.md"), "# System\n\nv2 changed\n", "utf8");
-
- const second = await packService.refreshProjectPack({ reason: "docs_churn" });
- const secondFingerprint = readContextFingerprint(second.body);
-
- expect(secondFingerprint).not.toBe(firstFingerprint);
-
- const exportLite = await packService.getProjectExport({ level: "lite" });
- expect(exportLite.content).toContain(secondFingerprint);
- expect(exportLite.content).toContain("\"contextVersion\"");
- expect(exportLite.content).toContain("\"lastDocsRefreshAt\"");
- });
-});
diff --git a/apps/desktop/src/main/services/packs/packService.missionPack.test.ts b/apps/desktop/src/main/services/packs/packService.missionPack.test.ts
deleted file mode 100644
index 91c5a3c0..00000000
--- a/apps/desktop/src/main/services/packs/packService.missionPack.test.ts
+++ /dev/null
@@ -1,283 +0,0 @@
-import fs from "node:fs";
-import os from "node:os";
-import path from "node:path";
-import { describe, expect, it } from "vitest";
-import { createPackService } from "./packService";
-import { openKvDb } from "../state/kvDb";
-
-function createLogger() {
- return {
- debug: () => {},
- info: () => {},
- warn: () => {},
- error: () => {}
- } as any;
-}
-
-describe("packService mission pack", () => {
- it("refreshes mission packs with durable version/event metadata", async () => {
- const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pack-mission-"));
- const packsDir = path.join(projectRoot, ".ade", "packs");
- fs.mkdirSync(path.join(projectRoot, "docs", "architecture"), { recursive: true });
- fs.writeFileSync(path.join(projectRoot, "docs", "PRD.md"), "# PRD\n\nMission pack tests\n", "utf8");
-
- const db = await openKvDb(path.join(projectRoot, "ade.db"), createLogger());
- const projectId = "proj-1";
- const laneId = "lane-1";
- const missionId = "mission-1";
- const now = "2026-02-19T00:00:00.000Z";
-
- db.run(
- `
- insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at)
- values (?, ?, ?, ?, ?, ?)
- `,
- [projectId, projectRoot, "ADE", "main", now, now]
- );
- db.run(
- `
- insert into lanes(
- id,
- project_id,
- name,
- description,
- lane_type,
- base_ref,
- branch_ref,
- worktree_path,
- attached_root_path,
- is_edit_protected,
- parent_lane_id,
- color,
- icon,
- tags_json,
- status,
- created_at,
- archived_at
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- [
- laneId,
- projectId,
- "Lane 1",
- null,
- "worktree",
- "main",
- "feature/lane-1",
- projectRoot,
- null,
- 0,
- null,
- null,
- null,
- null,
- "active",
- now,
- null
- ]
- );
- db.run(
- `
- insert into missions(
- id,
- project_id,
- lane_id,
- title,
- prompt,
- status,
- priority,
- execution_mode,
- target_machine_id,
- outcome_summary,
- last_error,
- metadata_json,
- created_at,
- updated_at,
- started_at,
- completed_at
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- [missionId, projectId, laneId, "Mission 1", "Implement context hardening gate.", "in_progress", "high", "local", null, null, null, null, now, now, now, null]
- );
- db.run(
- `
- insert into mission_steps(
- id,
- mission_id,
- project_id,
- step_index,
- title,
- detail,
- kind,
- lane_id,
- status,
- metadata_json,
- created_at,
- updated_at,
- started_at,
- completed_at
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- ["mstep-1", missionId, projectId, 0, "Design runtime contracts", null, "manual", laneId, "running", null, now, now, now, null]
- );
- db.run(
- `
- insert into orchestrator_runs(
- id,
- project_id,
- mission_id,
- status,
- context_profile,
- scheduler_state,
- runtime_cursor_json,
- last_error,
- metadata_json,
- created_at,
- updated_at,
- started_at,
- completed_at
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- ["run-1", projectId, missionId, "active", "orchestrator_deterministic_v1", "active", null, null, null, now, now, now, null]
- );
- db.run(
- `
- insert into orchestrator_steps(
- id, run_id, project_id, step_key, title, status, step_index,
- created_at, updated_at
- ) values (?, ?, ?, 'step-1', 'Design runtime contracts', 'running', 0, ?, ?)
- `,
- ["ostep-1", "run-1", projectId, now, now]
- );
- db.run(
- `
- insert into orchestrator_attempts(
- id, run_id, step_id, project_id, attempt_number, status,
- executor_kind, context_profile, created_at
- ) values (?, ?, ?, ?, 1, 'running', 'codex', 'orchestrator_deterministic_v1', ?)
- `,
- ["attempt-1", "run-1", "ostep-1", projectId, now]
- );
- db.run(
- `
- insert into mission_step_handoffs(
- id,
- project_id,
- mission_id,
- mission_step_id,
- run_id,
- step_id,
- attempt_id,
- handoff_type,
- producer,
- payload_json,
- created_at
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- ["handoff-1", projectId, missionId, "mstep-1", "run-1", "ostep-1", "attempt-1", "attempt_started", "orchestrator", "{\"summary\":\"started\"}", now]
- );
-
- const packService = createPackService({
- db,
- logger: createLogger(),
- projectRoot,
- projectId,
- packsDir,
- laneService: {
- list: async () => [
- {
- id: laneId,
- name: "Lane 1",
- description: null,
- laneType: "worktree",
- baseRef: "main",
- branchRef: "feature/lane-1",
- worktreePath: projectRoot,
- attachedRootPath: null,
- isEditProtected: false,
- parentLaneId: null,
- color: null,
- icon: null,
- tags: [],
- status: { dirty: false, ahead: 0, behind: 0 },
- createdAt: now,
- archivedAt: null,
- stackDepth: 0
- }
- ],
- getLaneBaseAndBranch: () => ({ worktreePath: projectRoot, baseRef: "main", branchRef: "feature/lane-1" })
- } as any,
- sessionService: { readTranscriptTail: () => "" } as any,
- projectConfigService: {
- get: () => ({
- local: { providers: {} },
- effective: {
- providerMode: "guest",
- providers: {},
- processes: [],
- testSuites: [],
- stackButtons: []
- }
- })
- } as any,
- operationService: {
- start: () => ({ operationId: "op-1" }),
- finish: () => {}
- } as any
- });
-
- const refreshed = await packService.refreshMissionPack({
- missionId,
- reason: "test_refresh",
- runId: "run-1"
- });
-
- expect(refreshed.packType).toBe("mission");
- expect(refreshed.packKey).toBe(`mission:${missionId}`);
- expect(refreshed.exists).toBe(true);
- expect(refreshed.versionId).toBeTruthy();
- expect(refreshed.versionNumber).toBeGreaterThan(0);
- expect(refreshed.contentHash).toBeTruthy();
- expect(refreshed.body).toContain("Mission Pack:");
- expect(refreshed.body).toContain("Step Handoffs");
- expect(refreshed.body).toContain("attempt_started");
- expect(refreshed.body).toContain("Orchestrator Runs");
-
- const fetched = packService.getMissionPack(missionId);
- expect(fetched.exists).toBe(true);
- expect(fetched.packType).toBe("mission");
- expect(fetched.versionId).toBe(refreshed.versionId);
- expect(fetched.versionNumber).toBe(refreshed.versionNumber);
-
- const versionCount = db.get<{ count: number }>(
- "select count(*) as count from pack_versions where project_id = ? and pack_key = ?",
- [projectId, `mission:${missionId}`]
- );
- expect(Number(versionCount?.count ?? 0)).toBeGreaterThan(0);
-
- const refreshEventCount = db.get<{ count: number }>(
- "select count(*) as count from pack_events where project_id = ? and pack_key = ? and event_type = ?",
- [projectId, `mission:${missionId}`, "refresh_triggered"]
- );
- expect(Number(refreshEventCount?.count ?? 0)).toBeGreaterThan(0);
-
- const indexRow = db.get<{ metadata_json: string | null; pack_type: string }>(
- "select metadata_json, pack_type from packs_index where project_id = ? and pack_key = ? limit 1",
- [projectId, `mission:${missionId}`]
- );
- expect(indexRow?.pack_type).toBe("mission");
- const metadata = (() => {
- try {
- return indexRow?.metadata_json ? (JSON.parse(indexRow.metadata_json) as Record) : null;
- } catch {
- return null;
- }
- })();
- expect(metadata?.reason).toBe("test_refresh");
- expect(metadata?.runId).toBe("run-1");
- expect(typeof metadata?.versionId).toBe("string");
- expect(typeof metadata?.contentHash).toBe("string");
-
- db.close();
- });
-});
diff --git a/apps/desktop/src/main/services/packs/packService.ts b/apps/desktop/src/main/services/packs/packService.ts
deleted file mode 100644
index 53af3caa..00000000
--- a/apps/desktop/src/main/services/packs/packService.ts
+++ /dev/null
@@ -1,2680 +0,0 @@
-import { createHash, randomUUID } from "node:crypto";
-import fs from "node:fs";
-import path from "node:path";
-import { createContextDocService } from "../context/contextDocService";
-import { runGit } from "../git/git";
-import type { Logger } from "../logging/logger";
-import type { AdeDb } from "../state/kvDb";
-import type { createLaneService } from "../lanes/laneService";
-import type { createSessionService } from "../sessions/sessionService";
-import { createSessionDeltaService } from "../sessions/sessionDeltaService";
-import type { createProjectConfigService } from "../config/projectConfigService";
-import type { createOperationService } from "../history/operationService";
-import { nowIso, uniqueSorted } from "../shared/utils";
-import type { createAiIntegrationService } from "../ai/aiIntegrationService";
-import type {
- Checkpoint,
- ConflictLineageV1,
- ContextExportLevel,
- ContextHeaderV1,
- ConflictRiskLevel,
- ConflictStatusValue,
- GetConflictExportArgs,
- GetLaneExportArgs,
- GetProjectExportArgs,
- LaneCompletionSignal,
- LaneExportManifestV1,
- LaneSummary,
- OrchestratorLaneSummaryV1,
- ListPackEventsSinceArgs,
- PackDeltaDigestArgs,
- PackDeltaDigestV1,
- PackDependencyStateV1,
- PackEvent,
- PackEventCategory,
- PackEventEntityRef,
- PackEventImportance,
- PackExport,
- PackHeadVersion,
- PackSummary,
- PackType,
- PackVersion,
- PackVersionSummary,
- ProjectExportManifestV1,
- ProjectManifestLaneEntryV1,
- SessionDeltaSummary,
- TestRunStatus
-} from "../../../shared/types";
-import {
- ADE_INTENT_END,
- ADE_INTENT_START,
- ADE_NARRATIVE_END,
- ADE_NARRATIVE_START,
- ADE_TASK_SPEC_END,
- ADE_TASK_SPEC_START,
- ADE_TODOS_END,
- ADE_TODOS_START,
- CONTEXT_HEADER_SCHEMA_V1,
- CONTEXT_CONTRACT_VERSION
-} from "../../../shared/contextContract";
-import { stripAnsi } from "../../utils/ansiStrip";
-import { inferTestOutcomeFromText } from "./transcriptInsights";
-import { renderLanePackMarkdown } from "./lanePackTemplate";
-import { computeSectionChanges, upsertSectionByHeading } from "./packSections";
-import { buildConflictExport, buildLaneExport, buildProjectExport } from "./packExports";
-import type { PackRelation } from "../../../shared/contextContract";
-
-// ── Extracted builder modules ────────────────────────────────────────────────
-import {
- safeJsonParseArray,
- readFileIfExists,
- ensureDirFor,
- ensureDir,
- safeSegment,
- parsePackMetadataJson,
- extractSection,
- statusFromCode,
- humanToolLabel,
- normalizeRiskLevel,
- asString,
- computeMergeReadiness,
- buildGraphEnvelope,
- importanceRank,
- getDefaultSectionLocators,
- upsertPackIndex,
- toPackSummaryFromRow,
- formatCommand,
- moduleFromPath
-} from "./packUtils";
-import {
- type ProjectPackBuilderDeps,
- buildProjectPackBody as buildProjectPackBodyImpl
-} from "./projectPackBuilder";
-import {
- type MissionPackBuilderDeps,
- buildMissionPackBody as buildMissionPackBodyImpl,
- buildPlanPackBody as buildPlanPackBodyImpl,
- buildFeaturePackBody as buildFeaturePackBodyImpl
-} from "./missionPackBuilder";
-import {
- type ConflictPackBuilderDeps,
- readConflictPredictionPack as readConflictPredictionPackImpl,
- readGitConflictState as readGitConflictStateImpl,
- deriveConflictStateForLane as deriveConflictStateForLaneImpl,
- computeLaneLineage as computeLaneLineageImpl,
- buildLaneConflictRiskSummaryLines as buildLaneConflictRiskSummaryLinesImpl,
- buildConflictPackBody as buildConflictPackBodyImpl
-} from "./conflictPackBuilder";
-
-function replaceNarrativeSection(existing: string, narrative: string): { updated: string; insertedMarkers: boolean } {
- const cleanNarrative = narrative.trim().length ? narrative.trim() : "Narrative generation returned empty content.";
- const next = upsertSectionByHeading({
- content: existing,
- heading: "## Narrative",
- startMarker: ADE_NARRATIVE_START,
- endMarker: ADE_NARRATIVE_END,
- body: cleanNarrative
- });
- return { updated: next.content, insertedMarkers: next.insertedMarkers };
-}
-
-export function createPackService({
- db,
- logger,
- projectRoot,
- projectId,
- packsDir,
- laneService,
- sessionService,
- projectConfigService,
- aiIntegrationService,
- operationService,
- onEvent
-}: {
- db: AdeDb;
- logger: Logger;
- projectRoot: string;
- projectId: string;
- packsDir: string;
- laneService: ReturnType;
- sessionService: ReturnType;
- projectConfigService: ReturnType;
- aiIntegrationService?: ReturnType;
- operationService: ReturnType;
- onEvent?: (event: PackEvent) => void;
-}) {
- const projectPackPath = path.join(packsDir, "project_pack.md");
-
- const getLanePackPath = (laneId: string) => path.join(packsDir, "lanes", laneId, "lane_pack.md");
- const getFeaturePackPath = (featureKey: string) => path.join(packsDir, "features", safeSegment(featureKey), "feature_pack.md");
- const getPlanPackPath = (laneId: string) => path.join(packsDir, "plans", laneId, "plan_pack.md");
- const getMissionPackPath = (missionId: string) => path.join(packsDir, "missions", missionId, "mission_pack.md");
- const getConflictPackPath = (laneId: string, peer: string) =>
- path.join(packsDir, "conflicts", "v2", `${laneId}__${safeSegment(peer)}.md`);
- const conflictsRootDir = path.join(packsDir, "conflicts");
- const conflictPredictionsDir = path.join(conflictsRootDir, "predictions");
- const getConflictPredictionPath = (laneId: string) => path.join(conflictPredictionsDir, `${laneId}.json`);
-
- const versionsDir = path.join(packsDir, "versions");
- const historyDir = path.join(path.dirname(packsDir), "history");
- const checkpointsDir = path.join(historyDir, "checkpoints");
- const eventsDir = path.join(historyDir, "events");
-
- const sha256 = (input: string): string => createHash("sha256").update(input).digest("hex");
-
- const inferPackTypeFromKey = (packKey: string): PackType => {
- if (packKey === "project") return "project";
- if (packKey.startsWith("lane:")) return "lane";
- if (packKey.startsWith("feature:")) return "feature";
- if (packKey.startsWith("conflict:")) return "conflict";
- if (packKey.startsWith("plan:")) return "plan";
- if (packKey.startsWith("mission:")) return "mission";
- return "project";
- };
-
- // ── Deps for extracted builders ─────────────────────────────────────────────
-
- const projectPackBuilderDeps: ProjectPackBuilderDeps = {
- db,
- logger,
- projectRoot,
- projectId,
- packsDir,
- laneService,
- projectConfigService,
- aiIntegrationService
- };
- const contextDocService = createContextDocService({
- ...projectPackBuilderDeps,
- packsDir,
- });
-
- const conflictPackBuilderDeps: ConflictPackBuilderDeps = {
- projectRoot,
- laneService,
- getConflictPredictionPath,
- getLanePackPath
- };
-
- const readConflictPredictionPack = (laneId: string) => readConflictPredictionPackImpl(conflictPackBuilderDeps, laneId);
- const readGitConflictState = (laneId: string) => readGitConflictStateImpl(conflictPackBuilderDeps, laneId);
- const deriveConflictStateForLane = (laneId: string) => deriveConflictStateForLaneImpl(conflictPackBuilderDeps, laneId);
- const computeLaneLineage = (args: { laneId: string; lanesById: Map }) => computeLaneLineageImpl(args);
- const buildLaneConflictRiskSummaryLines = (laneId: string) => buildLaneConflictRiskSummaryLinesImpl(conflictPackBuilderDeps, laneId);
-
- const readContextDocMeta = () => contextDocService.getDocMeta();
-
- const buildProjectPackBody = (args: { reason: string; deterministicUpdatedAt: string; sourceLaneId?: string }) =>
- buildProjectPackBodyImpl(projectPackBuilderDeps, args);
-
- // Mission builder deps are defined lazily (below) because they reference closures
- // (getHeadSha, getPackIndexRow) that are defined later in this function.
- // See `missionPackBuilderDeps` and its wrappers below the core infrastructure section.
-
- const findBaselineVersionAtOrBefore = (args: { packKey: string; sinceIso: string }): { id: string; versionNumber: number; createdAt: string } | null => {
- const row = db.get<{ id: string; version_number: number; created_at: string }>(
- `
- select id, version_number, created_at
- from pack_versions
- where project_id = ?
- and pack_key = ?
- and created_at <= ?
- order by created_at desc
- limit 1
- `,
- [projectId, args.packKey, args.sinceIso]
- );
- if (!row?.id) return null;
- return { id: row.id, versionNumber: Number(row.version_number ?? 0), createdAt: row.created_at };
- };
-
- const classifyPackEvent = (args: {
- packKey: string;
- eventType: string;
- createdAt: string;
- payload: Record;
- }): {
- importance: PackEventImportance;
- importanceScore: number;
- category: PackEventCategory;
- entityIds: string[];
- entityRefs: PackEventEntityRef[];
- actionType: string;
- rationale: string | null;
- } => {
- const eventType = args.eventType;
- const payload = args.payload ?? {};
-
- const entityIdsSet = new Set();
- const entityRefs: PackEventEntityRef[] = [];
-
- const addEntity = (kind: string, idRaw: unknown) => {
- const id = typeof idRaw === "string" ? idRaw.trim() : "";
- if (!id) return;
- entityIdsSet.add(id);
- entityRefs.push({ kind, id });
- };
-
- if (args.packKey.startsWith("lane:")) addEntity("lane", args.packKey.slice("lane:".length));
- if (args.packKey.startsWith("conflict:")) {
- const parts = args.packKey.split(":");
- if (parts.length >= 2) addEntity("lane", parts[1]);
- if (parts.length >= 3) addEntity("peer", parts.slice(2).join(":"));
- }
-
- addEntity("lane", payload.laneId);
- addEntity("lane", payload.peerLaneId);
- addEntity("session", payload.sessionId);
- addEntity("checkpoint", payload.checkpointId);
- addEntity("version", payload.versionId);
- addEntity("operation", payload.operationId);
- addEntity("job", payload.jobId);
- addEntity("artifact", payload.artifactId);
- addEntity("proposal", payload.proposalId);
-
- const category: PackEventCategory = (() => {
- if (eventType.startsWith("narrative_")) return "narrative";
- if (eventType === "checkpoint") return "session";
- if (eventType.includes("conflict")) return "conflict";
- if (eventType.includes("branch")) return "branch";
- return "pack";
- })();
-
- const importance: PackEventImportance = (() => {
- if (eventType === "narrative_update") return "high";
- if (eventType === "narrative_failed") return "high";
- if (eventType === "checkpoint") return "medium";
- if (eventType === "refresh_triggered") return "medium";
- if (eventType === "narrative_requested") return "medium";
- return "low";
- })();
-
- const importanceScore = importance === "high" ? 0.9 : importance === "medium" ? 0.6 : 0.25;
-
- const rationale = (() => {
- const trigger = typeof payload.trigger === "string" ? payload.trigger.trim() : "";
- if (trigger) return trigger;
- const source = typeof payload.source === "string" ? payload.source.trim() : "";
- if (source) return source;
- return null;
- })();
-
- return {
- importance,
- importanceScore,
- category,
- entityIds: Array.from(entityIdsSet),
- entityRefs,
- actionType: eventType,
- rationale
- };
- };
-
- const ensureEventMeta = (event: PackEvent): PackEvent => {
- const payload = (event.payload ?? {}) as Record;
- const hasMeta =
- payload.importance != null ||
- payload.importanceScore != null ||
- payload.category != null ||
- payload.entityIds != null ||
- payload.entityRefs != null ||
- payload.actionType != null ||
- payload.rationale != null;
- if (hasMeta) return event;
-
- const meta = classifyPackEvent({
- packKey: event.packKey,
- eventType: event.eventType,
- createdAt: event.createdAt,
- payload
- });
-
- return {
- ...event,
- payload: {
- ...payload,
- importance: meta.importance,
- importanceScore: meta.importanceScore,
- category: meta.category,
- entityIds: meta.entityIds,
- entityRefs: meta.entityRefs,
- actionType: meta.actionType,
- rationale: meta.rationale
- }
- };
- };
-
- const upsertEventMetaForInsert = (args: {
- packKey: string;
- eventType: string;
- createdAt: string;
- payload: Record;
- }): Record => {
- const payload = args.payload ?? {};
- const out: Record = { ...payload };
- const meta = classifyPackEvent(args);
-
- if (out.importance == null) out.importance = meta.importance;
- if (out.importanceScore == null) out.importanceScore = meta.importanceScore;
- if (out.category == null) out.category = meta.category;
- if (out.entityIds == null) out.entityIds = meta.entityIds;
- if (out.entityRefs == null) out.entityRefs = meta.entityRefs;
- if (out.actionType == null) out.actionType = meta.actionType;
- if (out.rationale == null) out.rationale = meta.rationale;
-
- return out;
- };
-
- const readGatewayMeta = (): { apiBaseUrl: string | null; remoteProjectId: string | null } => {
- return { apiBaseUrl: null, remoteProjectId: null };
- };
-
- const PACK_RETENTION_KEEP_DAYS = 14;
- const PACK_RETENTION_MAX_ARCHIVED_LANES = 25;
- const PACK_RETENTION_CLEANUP_INTERVAL_MS = 60 * 60_000;
- let lastCleanupAt = 0;
-
- const cleanupPacks = async (): Promise => {
- const lanes = await laneService.list({ includeArchived: true });
- const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const));
-
- const now = Date.now();
- const keepBeforeMs = now - PACK_RETENTION_KEEP_DAYS * 24 * 60 * 60_000;
-
- const lanesDir = path.join(packsDir, "lanes");
- const conflictsDir = path.join(packsDir, "conflicts");
-
- const archivedDirs: Array<{ laneId: string; archivedAtMs: number }> = [];
-
- if (fs.existsSync(lanesDir)) {
- for (const entry of fs.readdirSync(lanesDir, { withFileTypes: true })) {
- if (!entry.isDirectory()) continue;
- const laneId = entry.name;
- const lane = laneById.get(laneId);
- const absDir = path.join(lanesDir, laneId);
-
- if (!lane) {
- fs.rmSync(absDir, { recursive: true, force: true });
- continue;
- }
-
- if (!lane.archivedAt) continue;
- const ts = Date.parse(lane.archivedAt);
- const archivedAtMs = Number.isFinite(ts) ? ts : now;
- archivedDirs.push({ laneId, archivedAtMs });
- }
- }
-
- archivedDirs.sort((a, b) => b.archivedAtMs - a.archivedAtMs);
- const keepByCount = new Set(archivedDirs.slice(0, PACK_RETENTION_MAX_ARCHIVED_LANES).map((entry) => entry.laneId));
-
- for (const { laneId, archivedAtMs } of archivedDirs) {
- if (keepByCount.has(laneId) && archivedAtMs >= keepBeforeMs) continue;
- const absDir = path.join(lanesDir, laneId);
- fs.rmSync(absDir, { recursive: true, force: true });
- }
-
- if (fs.existsSync(conflictsDir)) {
- for (const entry of fs.readdirSync(conflictsDir, { withFileTypes: true })) {
- if (!entry.isFile()) continue;
- if (!entry.name.endsWith(".json")) continue;
- const laneId = entry.name.slice(0, -".json".length);
- const lane = laneById.get(laneId);
- if (!lane) {
- fs.rmSync(path.join(conflictsDir, entry.name), { force: true });
- continue;
- }
- if (!lane.archivedAt) continue;
- const ts = Date.parse(lane.archivedAt);
- const archivedAtMs = Number.isFinite(ts) ? ts : now;
- if (!keepByCount.has(laneId) || archivedAtMs < keepBeforeMs) {
- fs.rmSync(path.join(conflictsDir, entry.name), { force: true });
- }
- }
-
- // Conflict prediction summaries (v1) live under `conflicts/predictions/*.json`.
- const predictionsDir = path.join(conflictsDir, "predictions");
- if (fs.existsSync(predictionsDir)) {
- for (const entry of fs.readdirSync(predictionsDir, { withFileTypes: true })) {
- if (!entry.isFile()) continue;
- if (!entry.name.endsWith(".json")) continue;
- const laneId = entry.name.slice(0, -".json".length);
- const lane = laneById.get(laneId);
- const absPath = path.join(predictionsDir, entry.name);
- if (!lane) {
- fs.rmSync(absPath, { force: true });
- continue;
- }
- if (!lane.archivedAt) continue;
- const ts = Date.parse(lane.archivedAt);
- const archivedAtMs = Number.isFinite(ts) ? ts : now;
- if (!keepByCount.has(laneId) || archivedAtMs < keepBeforeMs) {
- fs.rmSync(absPath, { force: true });
- }
- }
- }
-
- // V2 conflict packs are stored as markdown files under `conflicts/v2/`.
- const v2Dir = path.join(conflictsDir, "v2");
- if (fs.existsSync(v2Dir)) {
- for (const entry of fs.readdirSync(v2Dir, { withFileTypes: true })) {
- if (!entry.isFile()) continue;
- if (!entry.name.endsWith(".md")) continue;
- const file = entry.name;
- const laneId = file.split("__")[0]?.trim() ?? "";
- if (!laneId) continue;
-
- const lane = laneById.get(laneId);
- const absPath = path.join(v2Dir, file);
- if (!lane) {
- fs.rmSync(absPath, { force: true });
- continue;
- }
- if (!lane.archivedAt) continue;
- const ts = Date.parse(lane.archivedAt);
- const archivedAtMs = Number.isFinite(ts) ? ts : now;
- if (!keepByCount.has(laneId) || archivedAtMs < keepBeforeMs) {
- fs.rmSync(absPath, { force: true });
- }
- }
- }
- }
- };
-
- const maybeCleanupPacks = () => {
- const now = Date.now();
- if (now - lastCleanupAt < PACK_RETENTION_CLEANUP_INTERVAL_MS) return;
- lastCleanupAt = now;
- void cleanupPacks().catch((error: unknown) => {
- logger.warn("packs.cleanup_failed", {
- error: error instanceof Error ? error.message : String(error)
- });
- });
- };
-
- const readCurrentPackVersion = (packKey: string): { versionId: string; versionNumber: number; contentHash: string; renderedPath: string } | null => {
- const row = db.get<{ id: string; version_number: number; content_hash: string; rendered_path: string }>(
- `
- select v.id as id, v.version_number as version_number, v.content_hash as content_hash, v.rendered_path as rendered_path
- from pack_heads h
- join pack_versions v on v.id = h.current_version_id and v.project_id = h.project_id
- where h.project_id = ?
- and h.pack_key = ?
- limit 1
- `,
- [projectId, packKey]
- );
- if (!row?.id) return null;
- return {
- versionId: row.id,
- versionNumber: Number(row.version_number ?? 0),
- contentHash: String(row.content_hash ?? ""),
- renderedPath: String(row.rendered_path ?? "")
- };
- };
-
- const createPackEvent = (args: { packKey: string; eventType: string; payload?: Record }): PackEvent => {
- const eventId = randomUUID();
- const createdAt = nowIso();
- const payload = upsertEventMetaForInsert({
- packKey: args.packKey,
- eventType: args.eventType,
- createdAt,
- payload: args.payload ?? {}
- });
-
- db.run(
- `
- insert into pack_events(
- id,
- project_id,
- pack_key,
- event_type,
- payload_json,
- created_at
- ) values (?, ?, ?, ?, ?, ?)
- `,
- [eventId, projectId, args.packKey, args.eventType, JSON.stringify(payload), createdAt]
- );
-
- const event: PackEvent = ensureEventMeta({ id: eventId, packKey: args.packKey, eventType: args.eventType, payload, createdAt });
-
- try {
- const monthKey = createdAt.slice(0, 7); // YYYY-MM
- const monthDir = path.join(eventsDir, monthKey);
- ensureDir(monthDir);
- fs.writeFileSync(
- path.join(monthDir, `${eventId}.json`),
- JSON.stringify(event, null, 2),
- "utf8"
- );
- } catch {
- // ignore event file write failures
- }
-
- try {
- onEvent?.(event);
- } catch {
- // ignore broadcast failures
- }
-
- return event;
- };
-
- const createPackVersion = (args: { packKey: string; packType: PackType; body: string }): { versionId: string; versionNumber: number; contentHash: string } => {
- const bodyHash = sha256(args.body);
- const existing = readCurrentPackVersion(args.packKey);
- if (existing && existing.contentHash === bodyHash) {
- return {
- versionId: existing.versionId,
- versionNumber: existing.versionNumber,
- contentHash: existing.contentHash
- };
- }
-
- const versionId = randomUUID();
- const createdAt = nowIso();
- const maxRow = db.get<{ max_version: number | null }>(
- "select max(version_number) as max_version from pack_versions where project_id = ? and pack_key = ?",
- [projectId, args.packKey]
- );
- const versionNumber = Number(maxRow?.max_version ?? 0) + 1;
- const renderedPath = path.join(versionsDir, `${versionId}.md`);
-
- ensureDir(versionsDir);
- fs.writeFileSync(renderedPath, args.body, "utf8");
-
- db.run(
- `
- insert into pack_versions(
- id,
- project_id,
- pack_key,
- version_number,
- content_hash,
- rendered_path,
- created_at
- ) values (?, ?, ?, ?, ?, ?, ?)
- `,
- [versionId, projectId, args.packKey, versionNumber, bodyHash, renderedPath, createdAt]
- );
-
- db.run(
- `
- insert into pack_heads(project_id, pack_key, current_version_id, updated_at)
- values (?, ?, ?, ?)
- on conflict(project_id, pack_key) do update set
- current_version_id = excluded.current_version_id,
- updated_at = excluded.updated_at
- `,
- [projectId, args.packKey, versionId, createdAt]
- );
-
- createPackEvent({
- packKey: args.packKey,
- eventType: "version_created",
- payload: {
- packKey: args.packKey,
- packType: args.packType,
- versionId,
- versionNumber,
- contentHash: bodyHash
- }
- });
-
- return { versionId, versionNumber, contentHash: bodyHash };
- };
-
- const persistPackRefresh = (args: {
- packKey: string;
- packType: PackType;
- packPath: string;
- laneId: string | null;
- body: string;
- deterministicUpdatedAt: string;
- narrativeUpdatedAt?: string | null;
- lastHeadSha?: string | null;
- metadata?: Record;
- eventType?: string;
- eventPayload?: Record;
- }): PackSummary => {
- ensureDirFor(args.packPath);
- fs.writeFileSync(args.packPath, args.body, "utf8");
-
- createPackEvent({
- packKey: args.packKey,
- eventType: args.eventType ?? "refresh_triggered",
- payload: args.eventPayload ?? {}
- });
-
- const version = createPackVersion({ packKey: args.packKey, packType: args.packType, body: args.body });
- const metadata = {
- ...(args.metadata ?? {}),
- versionId: version.versionId,
- versionNumber: version.versionNumber,
- contentHash: version.contentHash
- };
-
- upsertPackIndex({
- db,
- projectId,
- packKey: args.packKey,
- laneId: args.laneId,
- packType: args.packType,
- packPath: args.packPath,
- deterministicUpdatedAt: args.deterministicUpdatedAt,
- narrativeUpdatedAt: args.narrativeUpdatedAt ?? null,
- lastHeadSha: args.lastHeadSha ?? null,
- metadata
- });
-
- maybeCleanupPacks();
-
- return {
- packKey: args.packKey,
- packType: args.packType,
- path: args.packPath,
- exists: true,
- deterministicUpdatedAt: args.deterministicUpdatedAt,
- narrativeUpdatedAt: args.narrativeUpdatedAt ?? null,
- lastHeadSha: args.lastHeadSha ?? null,
- versionId: version.versionId,
- versionNumber: version.versionNumber,
- contentHash: version.contentHash,
- metadata,
- body: args.body
- };
- };
-
- const recordCheckpointFromDelta = (args: {
- laneId: string;
- sessionId: string;
- sha: string;
- delta: SessionDeltaSummary;
- }): Checkpoint | null => {
- const existing = db.get<{ id: string }>(
- "select id from checkpoints where project_id = ? and session_id = ? limit 1",
- [projectId, args.sessionId]
- );
- if (existing?.id) return null;
-
- const checkpointId = randomUUID();
- const createdAt = nowIso();
- const diffStat = {
- insertions: args.delta.insertions,
- deletions: args.delta.deletions,
- filesChanged: args.delta.filesChanged,
- files: args.delta.touchedFiles
- };
-
- const event = createPackEvent({
- packKey: `lane:${args.laneId}`,
- eventType: "checkpoint",
- payload: {
- checkpointId,
- laneId: args.laneId,
- sessionId: args.sessionId,
- sha: args.sha,
- diffStat
- }
- });
-
- try {
- ensureDir(checkpointsDir);
- fs.writeFileSync(
- path.join(checkpointsDir, `${checkpointId}.json`),
- JSON.stringify(
- {
- id: checkpointId,
- laneId: args.laneId,
- sessionId: args.sessionId,
- sha: args.sha,
- diffStat,
- packEventIds: [event.id],
- createdAt
- },
- null,
- 2
- ),
- "utf8"
- );
- } catch {
- // ignore checkpoint file write failures
- }
-
- db.run(
- `
- insert into checkpoints(
- id,
- project_id,
- lane_id,
- session_id,
- sha,
- diff_stat_json,
- pack_event_ids_json,
- created_at
- ) values (?, ?, ?, ?, ?, ?, ?, ?)
- `,
- [
- checkpointId,
- projectId,
- args.laneId,
- args.sessionId,
- args.sha,
- JSON.stringify(diffStat),
- JSON.stringify([event.id]),
- createdAt
- ]
- );
-
- return {
- id: checkpointId,
- laneId: args.laneId,
- sessionId: args.sessionId,
- sha: args.sha,
- diffStat,
- packEventIds: [event.id],
- createdAt
- };
- };
-
- const getHeadSha = async (worktreePath: string): Promise => {
- const res = await runGit(["rev-parse", "HEAD"], { cwd: worktreePath, timeoutMs: 8_000 });
- if (res.exitCode !== 0) return null;
- const sha = res.stdout.trim();
- return sha.length ? sha : null;
- };
- const sessionDeltaService = createSessionDeltaService({ db, projectId, laneService, sessionService });
-
- const buildLanePackBody = async ({
- laneId,
- reason,
- latestDelta,
- deterministicUpdatedAt
- }: {
- laneId: string;
- reason: string;
- latestDelta: SessionDeltaSummary | null;
- deterministicUpdatedAt: string;
- }): Promise<{ body: string; lastHeadSha: string | null }> => {
- const lanes = await laneService.list({ includeArchived: true });
- const lane = lanes.find((candidate) => candidate.id === laneId);
- if (!lane) throw new Error(`Lane not found: ${laneId}`);
- const primaryLane = lanes.find((candidate) => candidate.laneType === "primary") ?? null;
- const parentLane = lane.parentLaneId ? lanes.find((candidate) => candidate.id === lane.parentLaneId) ?? null : null;
-
- const existingBody = readFileIfExists(getLanePackPath(laneId));
- const userIntent = extractSection(existingBody, ADE_INTENT_START, ADE_INTENT_END, "Intent not set — click to add.");
- const userTodos = extractSection(existingBody, ADE_TODOS_START, ADE_TODOS_END, "");
-
- const taskSpecFallback = [
- "Problem Statement:",
- "- (what are we solving, and for whom?)",
- "",
- "Scope:",
- "- (what is included?)",
- "",
- "Non-goals:",
- "- (what is explicitly out of scope?)",
- "",
- "Acceptance Criteria:",
- "- [ ] (add checkable acceptance criteria)",
- "",
- "Constraints / Conventions:",
- "- (languages, frameworks, patterns, performance, security, etc.)",
- "",
- "Dependencies:",
- `- Parent lane: ${parentLane ? parentLane.name : "(none)"}`,
- "- Required merges: (list lanes/PRs that must land first)"
- ].join("\n");
- const taskSpec = extractSection(existingBody, ADE_TASK_SPEC_START, ADE_TASK_SPEC_END, taskSpecFallback);
-
- const providerMode = projectConfigService.get().effective.providerMode ?? "guest";
- const { worktreePath } = laneService.getLaneBaseAndBranch(laneId);
- const headSha = await getHeadSha(worktreePath);
-
- const isoTime = (value: string | null | undefined) => {
- const raw = typeof value === "string" ? value : "";
- return raw.length >= 16 ? raw.slice(11, 16) : raw;
- };
-
- const recentSessions = db.all<{
- id: string;
- title: string;
- goal: string | null;
- toolType: string | null;
- summary: string | null;
- lastOutputPreview: string | null;
- transcriptPath: string | null;
- resumeCommand: string | null;
- status: string;
- tracked: number;
- startedAt: string;
- endedAt: string | null;
- exitCode: number | null;
- filesChanged: number | null;
- insertions: number | null;
- deletions: number | null;
- touchedFilesJson: string | null;
- failureLinesJson: string | null;
- }>(
- `
- select
- s.id as id,
- s.title as title,
- s.goal as goal,
- s.tool_type as toolType,
- s.summary as summary,
- s.last_output_preview as lastOutputPreview,
- s.transcript_path as transcriptPath,
- s.resume_command as resumeCommand,
- s.status as status,
- s.tracked as tracked,
- s.started_at as startedAt,
- s.ended_at as endedAt,
- s.exit_code as exitCode,
- d.files_changed as filesChanged,
- d.insertions as insertions,
- d.deletions as deletions,
- d.touched_files_json as touchedFilesJson,
- d.failure_lines_json as failureLinesJson
- from terminal_sessions s
- left join session_deltas d on d.session_id = s.id
- where s.lane_id = ?
- order by s.started_at desc
- limit 30
- `,
- [laneId]
- );
-
- const sessionsTotal = Number(
- db.get<{ count: number }>("select count(1) as count from terminal_sessions where lane_id = ?", [laneId])?.count ?? 0
- );
- const sessionsRunning = Number(
- db.get<{ count: number }>(
- "select count(1) as count from terminal_sessions where lane_id = ? and status = 'running' and pty_id is not null",
- [laneId]
- )?.count ?? 0
- );
-
- const transcriptTailCache = new Map();
- const getTranscriptTail = async (transcriptPath: string | null): Promise => {
- const key = String(transcriptPath ?? "").trim();
- if (!key) return "";
- const cached = transcriptTailCache.get(key);
- if (cached != null) return cached;
- const tail = await sessionService.readTranscriptTail(key, 140_000);
- transcriptTailCache.set(key, tail);
- return tail;
- };
-
- const latestTest = db.get<{
- run_id: string;
- suite_id: string;
- suite_name: string | null;
- command_json: string | null;
- status: TestRunStatus;
- duration_ms: number | null;
- ended_at: string | null;
- }>(
- `
- select
- r.id as run_id,
- r.suite_key as suite_id,
- s.name as suite_name,
- s.command_json as command_json,
- r.status as status,
- r.duration_ms as duration_ms,
- r.ended_at as ended_at
- from test_runs r
- left join test_suites s on s.project_id = r.project_id and s.id = r.suite_key
- where r.project_id = ?
- and r.lane_id = ?
- order by started_at desc
- limit 1
- `,
- [projectId, laneId]
- );
-
- const validationLines: string[] = [];
- if (latestTest) {
- const suiteLabel = (latestTest.suite_name ?? latestTest.suite_id).trim();
- validationLines.push(
- `Tests: ${statusFromCode(latestTest.status)} (suite=${suiteLabel}, duration=${latestTest.duration_ms ?? 0}ms)`
- );
- if (latestTest.command_json) {
- try {
- const command = JSON.parse(latestTest.command_json) as unknown;
- validationLines.push(`Tests command: ${formatCommand(command)}`);
- } catch {
- // ignore
- }
- }
- } else {
- const latestEnded = recentSessions.find((s) => Boolean(s.endedAt));
- const transcriptTail = latestEnded ? await getTranscriptTail(latestEnded.transcriptPath) : "";
- const inferred = inferTestOutcomeFromText(transcriptTail);
- if (inferred) {
- validationLines.push(`Tests: ${inferred.status === "pass" ? "PASS" : "FAIL"} (inferred from terminal output)`);
- } else {
- validationLines.push("Tests: NOT RUN");
- }
- }
-
- const lintSession = recentSessions.find((s) => {
- const haystack = `${s.summary ?? ""} ${(s.goal ?? "")} ${s.title}`.toLowerCase();
- return haystack.includes("lint");
- });
- if (lintSession && lintSession.endedAt) {
- const lintStatus =
- lintSession.exitCode == null ? "ENDED" : lintSession.exitCode === 0 ? "PASS" : `FAIL (exit ${lintSession.exitCode})`;
- validationLines.push(`Lint: ${lintStatus}`);
- } else {
- validationLines.push("Lint: NOT RUN");
- }
-
- type FileDelta = { insertions: number | null; deletions: number | null };
- const deltas = new Map();
-
- const addDelta = (filePath: string, insRaw: string, delRaw: string) => {
- const file = filePath.trim();
- if (!file) return;
- const ins = insRaw === "-" ? null : Number(insRaw);
- const del = delRaw === "-" ? null : Number(delRaw);
- const prev = deltas.get(file);
-
- const next: FileDelta = {
- insertions: Number.isFinite(ins as number) ? (ins as number) : ins,
- deletions: Number.isFinite(del as number) ? (del as number) : del
- };
-
- if (!prev) {
- deltas.set(file, next);
- return;
- }
-
- // Sum numeric changes; if either side is binary/unknown (null), preserve null.
- deltas.set(file, {
- insertions: prev.insertions == null || next.insertions == null ? null : prev.insertions + next.insertions,
- deletions: prev.deletions == null || next.deletions == null ? null : prev.deletions + next.deletions
- });
- };
-
- const addNumstat = (stdout: string) => {
- for (const line of stdout.split("\n").map((l) => l.trim()).filter(Boolean)) {
- const parts = line.split("\t");
- if (parts.length < 3) continue;
- const insRaw = parts[0] ?? "0";
- const delRaw = parts[1] ?? "0";
- const filePath = parts.slice(2).join("\t").trim();
- addDelta(filePath, insRaw, delRaw);
- }
- };
-
- const mergeBaseSha = await (async (): Promise => {
- const headRef = headSha ?? "HEAD";
- const baseRef = lane.baseRef?.trim() || "HEAD";
- const res = await runGit(["merge-base", headRef, baseRef], { cwd: projectRoot, timeoutMs: 12_000 });
- if (res.exitCode !== 0) return null;
- const sha = res.stdout.trim();
- return sha.length ? sha : null;
- })();
-
- if (mergeBaseSha && (headSha ?? "HEAD") !== mergeBaseSha) {
- const diff = await runGit(["diff", "--numstat", `${mergeBaseSha}..${headSha ?? "HEAD"}`], { cwd: projectRoot, timeoutMs: 20_000 });
- if (diff.exitCode === 0) addNumstat(diff.stdout);
- }
-
- // Add unstaged + staged separately to avoid double-counting (git diff HEAD includes both).
- const unstaged = await runGit(["diff", "--numstat"], { cwd: worktreePath, timeoutMs: 20_000 });
- if (unstaged.exitCode === 0) addNumstat(unstaged.stdout);
- const staged = await runGit(["diff", "--numstat", "--cached"], { cwd: worktreePath, timeoutMs: 20_000 });
- if (staged.exitCode === 0) addNumstat(staged.stdout);
-
- const statusRes = await runGit(["status", "--porcelain=v1"], { cwd: worktreePath, timeoutMs: 8_000 });
- if (statusRes.exitCode === 0) {
- const statusLines = statusRes.stdout.split("\n").map(l => l.trimEnd()).filter(Boolean);
- const newUntrackedPaths: string[] = [];
-
- for (const line of statusLines) {
- const statusCode = line.slice(0, 2);
- const raw = line.slice(2).trim();
- const arrow = raw.indexOf("->");
- const rel = arrow >= 0 ? raw.slice(arrow + 2).trim() : raw;
- if (!rel) continue;
-
- if (!deltas.has(rel)) {
- if (statusCode === "??") {
- newUntrackedPaths.push(rel);
- } else {
- deltas.set(rel, { insertions: 0, deletions: 0 });
- }
- }
- }
-
- // Count lines for untracked new files so they don't report 0/0
- for (const rel of newUntrackedPaths) {
- try {
- const fullPath = path.join(worktreePath, rel);
- const content = await fs.promises.readFile(fullPath, "utf-8");
- const lineCount = content.split("\n").length;
- deltas.set(rel, { insertions: lineCount, deletions: 0 });
- } catch {
- deltas.set(rel, { insertions: 0, deletions: 0 });
- }
- }
- }
-
- if (!deltas.size && latestDelta?.touchedFiles?.length) {
- for (const rel of latestDelta.touchedFiles.slice(0, 120)) {
- if (!deltas.has(rel)) deltas.set(rel, { insertions: 0, deletions: 0 });
- }
- }
-
- const whatChangedLines = (() => {
- const files = [...deltas.keys()];
- if (!files.length) return [];
- const byModule = new Map();
- for (const file of files) {
- const module = moduleFromPath(file);
- const list = byModule.get(module) ?? [];
- list.push(file);
- byModule.set(module, list);
- }
- const entries = [...byModule.entries()]
- .map(([module, files]) => ({ module, files: files.sort(), count: files.length }))
- .sort((a, b) => b.count - a.count || a.module.localeCompare(b.module));
- return entries.slice(0, 12).map((entry) => {
- const examples = entry.files.slice(0, 3).join(", ");
- const suffix = entry.files.length > 3 ? `, +${entry.files.length - 3} more` : "";
- return `${entry.module}: ${entry.count} files (${examples}${suffix})`;
- });
- })();
-
- const inferredWhyLines = await (async (): Promise => {
- if (!mergeBaseSha) return [];
- const res = await runGit(["log", "--oneline", `${mergeBaseSha}..${headSha ?? "HEAD"}`, "-n", "15"], {
- cwd: projectRoot,
- timeoutMs: 12_000
- });
- if (res.exitCode !== 0) return [];
- return res.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
- })();
-
- const keyFiles = (() => {
- const scored = [...deltas.entries()].map(([file, delta]) => {
- const magnitude =
- delta.insertions == null || delta.deletions == null ? Number.MAX_SAFE_INTEGER : delta.insertions + delta.deletions;
- return { file, insertions: delta.insertions, deletions: delta.deletions, magnitude };
- });
- return scored
- .sort((a, b) => b.magnitude - a.magnitude || a.file.localeCompare(b.file))
- .slice(0, 25)
- .map(({ magnitude: _magnitude, ...rest }) => rest);
- })();
-
- const errors = (() => {
- const raw = latestDelta?.failureLines ?? [];
- const out: string[] = [];
- const seen = new Set();
- for (const entry of raw) {
- const clean = stripAnsi(entry).trim().replace(/\s+/g, " ");
- if (!clean) continue;
- // Filter to the current detection heuristic so stale session_deltas rows don't spam packs.
- if (!/\b(error|failed|exception|fatal|traceback)\b/i.test(clean)) continue;
- const clipped = clean.length > 220 ? `${clean.slice(0, 219)}…` : clean;
- if (seen.has(clipped)) continue;
- seen.add(clipped);
- out.push(clipped);
- }
- return out;
- })();
-
- const sessionsDetailed = recentSessions.slice(0, 30).map((session) => {
- const tool = humanToolLabel(session.toolType);
- const goal = (session.goal ?? "").trim() || session.title;
- const result =
- session.endedAt == null
- ? "running"
- : session.exitCode == null
- ? "ended"
- : session.exitCode === 0
- ? "ok"
- : `exit ${session.exitCode}`;
- const delta =
- session.filesChanged != null ? `+${session.insertions ?? 0}/-${session.deletions ?? 0}` : "";
- const prompt = (session.resumeCommand ?? "").trim();
- const touchedFiles = safeJsonParseArray(session.touchedFilesJson);
- const failureLines = safeJsonParseArray(session.failureLinesJson);
- const commands: string[] = [];
- if (session.title && session.title !== goal) {
- commands.push(session.title);
- }
- return {
- when: isoTime(session.startedAt),
- tool,
- goal,
- result,
- delta,
- prompt,
- commands,
- filesTouched: touchedFiles.slice(0, 20),
- errors: failureLines.slice(0, 10)
- };
- });
-
- const nextSteps = (() => {
- const items: string[] = [];
- const intentSet = userIntent.trim().length && userIntent.trim() !== "Intent not set — click to add.";
- if (!intentSet) items.push("Set lane intent (Why section).");
- if (lane.status.dirty) items.push("Working tree is dirty; consider committing or stashing before switching lanes.");
- if (lane.status.behind > 0) items.push(`Lane is behind base by ${lane.status.behind} commits; consider syncing/rebasing.`);
- if (errors.length) items.push("Errors detected in the latest session output; review Errors & Issues.");
- if (latestTest && latestTest.status === "failed") items.push("Latest test run failed; fix failures before merging.");
- if (sessionsRunning > 0) items.push(`${sessionsRunning} terminal session(s) currently running.`);
-
- const latestFailedSession = recentSessions.find((s) => s.endedAt && (s.exitCode ?? 0) !== 0);
- if (latestFailedSession?.summary) items.push(`Recent failure: ${latestFailedSession.summary}`);
-
- return items;
- })();
-
- const requiredMerges = parentLane ? [parentLane.id] : [];
- const conflictState = deriveConflictStateForLane(laneId);
- const dependencyState: PackDependencyStateV1 = {
- requiredMerges,
- blockedByLanes: requiredMerges,
- mergeReadiness: computeMergeReadiness({
- requiredMerges,
- behindCount: lane.status.behind,
- conflictStatus: (conflictState?.status ?? null) as ConflictStatusValue | null
- })
- };
-
- const graph = buildGraphEnvelope(
- [
- {
- relationType: "depends_on",
- targetPackKey: "project",
- targetPackType: "project",
- rationale: "Lane context depends on project baseline."
- },
- ...(parentLane
- ? ([
- {
- relationType: "blocked_by",
- targetPackKey: `lane:${parentLane.id}`,
- targetPackType: "lane",
- targetLaneId: parentLane.id,
- targetBranch: parentLane.branchRef,
- rationale: "Lane is stacked on parent lane."
- },
- {
- relationType: "merges_into",
- targetPackKey: `lane:${parentLane.id}`,
- targetPackType: "lane",
- targetLaneId: parentLane.id,
- targetBranch: parentLane.branchRef,
- rationale: "Stacked lane merges into parent lane first."
- }
- ] satisfies PackRelation[])
- : [])
- ] satisfies PackRelation[]
- );
-
- const body = renderLanePackMarkdown({
- packKey: `lane:${laneId}`,
- projectId,
- laneId,
- laneName: lane.name,
- branchRef: lane.branchRef,
- baseRef: lane.baseRef,
- headSha,
- dirty: lane.status.dirty,
- ahead: lane.status.ahead,
- behind: lane.status.behind,
- parentName: parentLane?.name ?? (primaryLane && lane.laneType !== "primary" ? `${primaryLane.name} (primary)` : null),
- deterministicUpdatedAt,
- trigger: reason,
- providerMode,
- graph,
- dependencyState,
- conflictState,
- whatChangedLines,
- inferredWhyLines,
- userIntentMarkers: { start: ADE_INTENT_START, end: ADE_INTENT_END },
- userIntent,
- taskSpecMarkers: { start: ADE_TASK_SPEC_START, end: ADE_TASK_SPEC_END },
- taskSpec,
- validationLines,
- keyFiles,
- errors,
- sessionsDetailed,
- sessionsTotal: Number.isFinite(sessionsTotal) ? sessionsTotal : 0,
- sessionsRunning: Number.isFinite(sessionsRunning) ? sessionsRunning : 0,
- nextSteps,
- userTodosMarkers: { start: ADE_TODOS_START, end: ADE_TODOS_END },
- userTodos,
- laneDescription: lane.description ?? ""
- });
-
- return { body, lastHeadSha: headSha };
- };
-
- const getPackIndexRow = (packKey: string): {
- pack_type: PackType;
- lane_id: string | null;
- pack_path: string;
- deterministic_updated_at: string | null;
- narrative_updated_at: string | null;
- last_head_sha: string | null;
- metadata_json: string | null;
- } | null => {
- return db.get<{
- pack_type: PackType;
- lane_id: string | null;
- pack_path: string;
- deterministic_updated_at: string | null;
- narrative_updated_at: string | null;
- last_head_sha: string | null;
- metadata_json: string | null;
- }>(
- `
- select
- pack_type,
- lane_id,
- pack_path,
- deterministic_updated_at,
- narrative_updated_at,
- last_head_sha,
- metadata_json
- from packs_index
- where pack_key = ?
- and project_id = ?
- limit 1
- `,
- [packKey, projectId]
- );
- };
-
- const getPackSummaryForKey = (packKey: string, fallback: { packType: PackType; packPath: string }): PackSummary => {
- const row = getPackIndexRow(packKey);
- const effectiveRow = row ?? {
- pack_type: fallback.packType,
- lane_id: null,
- pack_path: fallback.packPath,
- deterministic_updated_at: null,
- narrative_updated_at: null,
- last_head_sha: null,
- metadata_json: null
- };
- const version = readCurrentPackVersion(packKey);
- return toPackSummaryFromRow({ packKey, row: effectiveRow, version });
- };
-
- const buildLiveLanePackSummary = async (args: {
- laneId: string;
- reason: string;
- }): Promise => {
- const storedPack = getPackSummaryForKey(`lane:${args.laneId}`, {
- packType: "lane",
- packPath: getLanePackPath(args.laneId)
- });
- const deterministicUpdatedAt = nowIso();
- const latestDelta = sessionDeltaService.listRecentLaneSessionDeltas(args.laneId, 1)[0] ?? null;
- const { body, lastHeadSha } = await buildLanePackBody({
- laneId: args.laneId,
- reason: args.reason,
- latestDelta,
- deterministicUpdatedAt
- });
- return buildLivePackSummary({
- storedPack,
- body,
- deterministicUpdatedAt,
- lastHeadSha
- });
- };
-
- const buildLiveProjectPackSummary = async (args: {
- reason: string;
- sourceLaneId?: string;
- }): Promise => {
- const storedPack = getPackSummaryForKey("project", {
- packType: "project",
- packPath: projectPackPath
- });
- const deterministicUpdatedAt = nowIso();
- const body = await buildProjectPackBody({
- reason: args.reason,
- deterministicUpdatedAt,
- sourceLaneId: args.sourceLaneId
- });
- return buildLivePackSummary({
- storedPack,
- body,
- deterministicUpdatedAt
- });
- };
-
- const buildLiveConflictPackSummary = async (args: {
- laneId: string;
- peerLaneId: string | null;
- reason: string;
- }): Promise => {
- const lane = laneService.getLaneBaseAndBranch(args.laneId);
- const peerKey = args.peerLaneId ?? lane.baseRef;
- const storedPack = getPackSummaryForKey(`conflict:${args.laneId}:${peerKey}`, {
- packType: "conflict",
- packPath: getConflictPackPath(args.laneId, peerKey)
- });
- const deterministicUpdatedAt = nowIso();
- const { body, lastHeadSha } = await buildConflictPackBody({
- laneId: args.laneId,
- peerLaneId: args.peerLaneId,
- reason: args.reason,
- deterministicUpdatedAt
- });
- return buildLivePackSummary({
- storedPack,
- body,
- deterministicUpdatedAt,
- lastHeadSha
- });
- };
-
- const buildLivePlanPackSummary = async (args: {
- laneId: string;
- reason: string;
- }): Promise => {
- const storedPack = getPackSummaryForKey(`plan:${args.laneId}`, {
- packType: "plan",
- packPath: getPlanPackPath(args.laneId)
- });
- const deterministicUpdatedAt = nowIso();
- const { body, headSha } = await buildPlanPackBody({
- laneId: args.laneId,
- reason: args.reason,
- deterministicUpdatedAt
- });
- return buildLivePackSummary({
- storedPack,
- body,
- deterministicUpdatedAt,
- lastHeadSha: headSha
- });
- };
-
- const buildLiveFeaturePackSummary = async (args: {
- featureKey: string;
- reason: string;
- }): Promise => {
- const storedPack = getPackSummaryForKey(`feature:${args.featureKey}`, {
- packType: "feature",
- packPath: getFeaturePackPath(args.featureKey)
- });
- const deterministicUpdatedAt = nowIso();
- const { body } = await buildFeaturePackBody({
- featureKey: args.featureKey,
- reason: args.reason,
- deterministicUpdatedAt
- });
- return buildLivePackSummary({
- storedPack,
- body,
- deterministicUpdatedAt
- });
- };
-
- const buildLiveMissionPackSummary = async (args: {
- missionId: string;
- reason: string;
- }): Promise => {
- const storedPack = getPackSummaryForKey(`mission:${args.missionId}`, {
- packType: "mission",
- packPath: getMissionPackPath(args.missionId)
- });
- const deterministicUpdatedAt = nowIso();
- const { body } = await buildMissionPackBody({
- missionId: args.missionId,
- reason: args.reason,
- deterministicUpdatedAt,
- runId: null
- });
- return buildLivePackSummary({
- storedPack,
- body,
- deterministicUpdatedAt
- });
- };
-
- // ── Mission/Plan/Feature builder wrappers ────────────────────────────────────
-
- const missionPackBuilderDeps: MissionPackBuilderDeps = {
- db,
- logger,
- projectRoot,
- projectId,
- packsDir,
- laneService,
- projectConfigService,
- getLanePackBody: async (laneId: string) => {
- const pack = await buildLiveLanePackSummary({
- laneId,
- reason: "context_export_dependency"
- });
- return pack.body;
- },
- readConflictPredictionPack,
- getHeadSha,
- getPackIndexRow
- };
-
- const buildFeaturePackBody = (args: { featureKey: string; reason: string; deterministicUpdatedAt: string }) =>
- buildFeaturePackBodyImpl(missionPackBuilderDeps, args);
- const buildPlanPackBody = (args: { laneId: string; reason: string; deterministicUpdatedAt: string }) =>
- buildPlanPackBodyImpl(missionPackBuilderDeps, args);
- const buildMissionPackBody = (args: { missionId: string; reason: string; deterministicUpdatedAt: string; runId?: string | null }) =>
- buildMissionPackBodyImpl(missionPackBuilderDeps, args);
- const buildConflictPackBody = (args: { laneId: string; peerLaneId: string | null; reason: string; deterministicUpdatedAt: string }) =>
- buildConflictPackBodyImpl(conflictPackBuilderDeps, args);
-
- const buildLivePackSummary = (args: {
- storedPack: PackSummary;
- body: string;
- deterministicUpdatedAt: string | null;
- narrativeUpdatedAt?: string | null;
- lastHeadSha?: string | null;
- metadata?: Record | null;
- }): PackSummary => {
- const contentHash = sha256(args.body);
- const reuseStoredVersion =
- typeof args.storedPack.contentHash === "string" &&
- args.storedPack.contentHash === contentHash &&
- typeof args.storedPack.versionId === "string" &&
- args.storedPack.versionId.trim().length > 0;
- const versionId = reuseStoredVersion
- ? args.storedPack.versionId ?? null
- : `live:${args.storedPack.packKey}:${contentHash.slice(0, 16)}`;
- const versionNumber = reuseStoredVersion ? args.storedPack.versionNumber ?? null : null;
-
- return {
- ...args.storedPack,
- exists: args.body.trim().length > 0 || args.storedPack.exists,
- deterministicUpdatedAt: args.deterministicUpdatedAt,
- narrativeUpdatedAt: args.narrativeUpdatedAt ?? args.storedPack.narrativeUpdatedAt ?? null,
- lastHeadSha: args.lastHeadSha ?? args.storedPack.lastHeadSha ?? null,
- versionId,
- versionNumber,
- contentHash,
- metadata: args.metadata ?? args.storedPack.metadata ?? null,
- body: args.body
- };
- };
-
- const buildBasicExport = (args: {
- packKey: string;
- packType: "feature" | "plan" | "mission";
- level: ContextExportLevel;
- pack: PackSummary;
- }): PackExport => {
- const header = {
- schema: CONTEXT_HEADER_SCHEMA_V1,
- contractVersion: CONTEXT_CONTRACT_VERSION,
- projectId,
- packKey: args.packKey,
- packType: args.packType,
- exportLevel: args.level,
- deterministicUpdatedAt: args.pack.deterministicUpdatedAt ?? null,
- narrativeUpdatedAt: args.pack.narrativeUpdatedAt ?? null,
- versionId: args.pack.versionId ?? null,
- versionNumber: args.pack.versionNumber ?? null,
- contentHash: args.pack.contentHash ?? null
- } satisfies ContextHeaderV1;
- const content = args.pack.body;
- const approxTokens = Math.ceil(content.length / 4);
- const maxTokens = args.level === "lite" ? 30_000 : args.level === "standard" ? 60_000 : 120_000;
- const truncated = approxTokens > maxTokens;
- const finalContent = truncated ? content.slice(0, maxTokens * 4) : content;
- return {
- packKey: args.packKey,
- packType: args.packType,
- level: args.level,
- header,
- content: finalContent,
- approxTokens: Math.ceil(finalContent.length / 4),
- maxTokens,
- truncated,
- warnings: truncated ? [`${args.packType[0]!.toUpperCase()}${args.packType.slice(1)} pack content was truncated to fit token budget.`] : []
- };
- };
-
- return {
- getProjectPack(): PackSummary {
- const row = db.get<{
- pack_type: PackType;
- pack_path: string;
- deterministic_updated_at: string | null;
- narrative_updated_at: string | null;
- last_head_sha: string | null;
- metadata_json: string | null;
- }>(
- `
- select
- pack_type,
- pack_path,
- deterministic_updated_at,
- narrative_updated_at,
- last_head_sha,
- metadata_json
- from packs_index
- where pack_key = 'project'
- and project_id = ?
- limit 1
- `,
- [projectId]
- );
-
- const version = readCurrentPackVersion("project");
- if (row) return toPackSummaryFromRow({ packKey: "project", row, version });
-
- const body = readFileIfExists(projectPackPath);
- const exists = fs.existsSync(projectPackPath);
- return {
- packKey: "project",
- packType: "project",
- path: projectPackPath,
- exists,
- deterministicUpdatedAt: null,
- narrativeUpdatedAt: null,
- lastHeadSha: null,
- versionId: version?.versionId ?? null,
- versionNumber: version?.versionNumber ?? null,
- contentHash: version?.contentHash ?? null,
- metadata: null,
- body
- };
- },
-
- getLanePack(laneId: string): PackSummary {
- const row = db.get<{
- pack_type: PackType;
- pack_path: string;
- deterministic_updated_at: string | null;
- narrative_updated_at: string | null;
- last_head_sha: string | null;
- metadata_json: string | null;
- }>(
- `
- select
- pack_type,
- pack_path,
- deterministic_updated_at,
- narrative_updated_at,
- last_head_sha,
- metadata_json
- from packs_index
- where pack_key = ?
- and project_id = ?
- limit 1
- `,
- [`lane:${laneId}`, projectId]
- );
-
- const packKey = `lane:${laneId}`;
- const version = readCurrentPackVersion(packKey);
- if (row) return toPackSummaryFromRow({ packKey, row, version });
-
- const lanePackPath = getLanePackPath(laneId);
- const body = readFileIfExists(lanePackPath);
- const exists = fs.existsSync(lanePackPath);
- return {
- packKey,
- packType: "lane",
- path: lanePackPath,
- exists,
- deterministicUpdatedAt: null,
- narrativeUpdatedAt: null,
- lastHeadSha: null,
- versionId: version?.versionId ?? null,
- versionNumber: version?.versionNumber ?? null,
- contentHash: version?.contentHash ?? null,
- metadata: null,
- body
- };
- },
-
- getFeaturePack(featureKey: string): PackSummary {
- const key = featureKey.trim();
- if (!key) throw new Error("featureKey is required");
- const packKey = `feature:${key}`;
- return getPackSummaryForKey(packKey, { packType: "feature", packPath: getFeaturePackPath(key) });
- },
-
- getConflictPack(args: { laneId: string; peerLaneId?: string | null }): PackSummary {
- const laneId = args.laneId.trim();
- if (!laneId) throw new Error("laneId is required");
- const peer = args.peerLaneId?.trim() || null;
- const lane = laneService.getLaneBaseAndBranch(laneId);
- const peerKey = peer ?? lane.baseRef;
- const packKey = `conflict:${laneId}:${peerKey}`;
- return getPackSummaryForKey(packKey, { packType: "conflict", packPath: getConflictPackPath(laneId, peerKey) });
- },
-
- getPlanPack(laneId: string): PackSummary {
- const id = laneId.trim();
- if (!id) throw new Error("laneId is required");
- const packKey = `plan:${id}`;
- return getPackSummaryForKey(packKey, { packType: "plan", packPath: getPlanPackPath(id) });
- },
-
- getMissionPack(missionId: string): PackSummary {
- const id = missionId.trim();
- if (!id) throw new Error("missionId is required");
- const packKey = `mission:${id}`;
- return getPackSummaryForKey(packKey, { packType: "mission", packPath: getMissionPackPath(id) });
- },
-
- async refreshLanePack(args: { laneId: string; reason: string; sessionId?: string }): Promise {
- const op = operationService.start({
- laneId: args.laneId,
- kind: "pack_update_lane",
- metadata: {
- reason: args.reason,
- sessionId: args.sessionId ?? null
- }
- });
-
- try {
- const latestDelta = args.sessionId
- ? await sessionDeltaService.computeSessionDelta(args.sessionId)
- : sessionDeltaService.listRecentLaneSessionDeltas(args.laneId, 1)[0] ?? null;
- const deterministicUpdatedAt = nowIso();
- const { body, lastHeadSha } = await buildLanePackBody({
- laneId: args.laneId,
- reason: args.reason,
- latestDelta,
- deterministicUpdatedAt
- });
-
- const packKey = `lane:${args.laneId}`;
- const packPath = getLanePackPath(args.laneId);
- const summary = persistPackRefresh({
- packKey,
- packType: "lane",
- packPath,
- laneId: args.laneId,
- body,
- deterministicUpdatedAt,
- narrativeUpdatedAt: null,
- lastHeadSha,
- metadata: {
- reason: args.reason,
- sessionId: args.sessionId ?? null,
- latestDeltaSessionId: latestDelta?.sessionId ?? null,
- operationId: op.operationId
- },
- eventType: "refresh_triggered",
- eventPayload: {
- operationId: op.operationId,
- trigger: args.reason,
- laneId: args.laneId,
- sessionId: args.sessionId ?? null
- }
- });
-
- if (args.sessionId && latestDelta) {
- const checkpointSha =
- lastHeadSha ??
- latestDelta.headShaEnd ??
- latestDelta.headShaStart ??
- null;
- if (checkpointSha) {
- recordCheckpointFromDelta({
- laneId: args.laneId,
- sessionId: args.sessionId,
- sha: checkpointSha,
- delta: latestDelta
- });
- }
- }
-
- operationService.finish({
- operationId: op.operationId,
- status: "succeeded",
- postHeadSha: lastHeadSha,
- metadataPatch: {
- packPath,
- deterministicUpdatedAt,
- latestDeltaSessionId: latestDelta?.sessionId ?? null,
- versionId: summary.versionId ?? null,
- versionNumber: summary.versionNumber ?? null
- }
- });
- return summary;
- } catch (error) {
- operationService.finish({
- operationId: op.operationId,
- status: "failed",
- metadataPatch: {
- error: error instanceof Error ? error.message : String(error)
- }
- });
- throw error;
- }
- },
-
- async refreshProjectPack(args: { reason: string; laneId?: string }): Promise {
- const op = operationService.start({
- laneId: args.laneId ?? null,
- kind: "pack_update_project",
- metadata: {
- reason: args.reason,
- sourceLaneId: args.laneId ?? null
- }
- });
-
- try {
- const deterministicUpdatedAt = nowIso();
- const body = await buildProjectPackBody({
- reason: args.reason,
- deterministicUpdatedAt,
- sourceLaneId: args.laneId
- });
-
- const packKey = "project";
- const summary = persistPackRefresh({
- packKey,
- packType: "project",
- packPath: projectPackPath,
- laneId: null,
- body,
- deterministicUpdatedAt,
- narrativeUpdatedAt: null,
- lastHeadSha: null,
- metadata: {
- ...readContextDocMeta(),
- reason: args.reason,
- sourceLaneId: args.laneId ?? null,
- operationId: op.operationId
- },
- eventType: "refresh_triggered",
- eventPayload: {
- operationId: op.operationId,
- trigger: args.reason,
- laneId: args.laneId ?? null
- }
- });
-
- operationService.finish({
- operationId: op.operationId,
- status: "succeeded",
- metadataPatch: {
- packPath: projectPackPath,
- deterministicUpdatedAt,
- versionId: summary.versionId ?? null,
- versionNumber: summary.versionNumber ?? null
- }
- });
- return summary;
- } catch (error) {
- operationService.finish({
- operationId: op.operationId,
- status: "failed",
- metadataPatch: {
- error: error instanceof Error ? error.message : String(error)
- }
- });
- throw error;
- }
- },
-
- async refreshMissionPack(args: { missionId: string; reason: string; runId?: string | null }): Promise {
- const missionId = args.missionId.trim();
- if (!missionId) throw new Error("missionId is required");
- const packKey = `mission:${missionId}`;
- const deterministicUpdatedAt = nowIso();
- const built = await buildMissionPackBody({
- missionId,
- reason: args.reason,
- deterministicUpdatedAt,
- runId: args.runId ?? null
- });
- const packPath = getMissionPackPath(missionId);
- return persistPackRefresh({
- packKey,
- packType: "mission",
- packPath,
- laneId: built.laneId,
- body: built.body,
- deterministicUpdatedAt,
- narrativeUpdatedAt: null,
- lastHeadSha: null,
- metadata: {
- reason: args.reason,
- missionId,
- runId: args.runId ?? null
- },
- eventType: "refresh_triggered",
- eventPayload: {
- trigger: args.reason,
- missionId,
- runId: args.runId ?? null
- }
- });
- },
-
- getVersion(versionId: string): PackVersion {
- const id = versionId.trim();
- if (!id) throw new Error("versionId is required");
- const row = db.get<{
- id: string;
- pack_key: string;
- version_number: number;
- content_hash: string;
- rendered_path: string;
- created_at: string;
- }>(
- `
- select id, pack_key, version_number, content_hash, rendered_path, created_at
- from pack_versions
- where project_id = ?
- and id = ?
- limit 1
- `,
- [projectId, id]
- );
- if (!row) throw new Error(`Pack version not found: ${id}`);
- const packType = getPackIndexRow(row.pack_key)?.pack_type ?? inferPackTypeFromKey(row.pack_key);
- return {
- id: row.id,
- packKey: row.pack_key,
- packType,
- versionNumber: Number(row.version_number ?? 0),
- contentHash: String(row.content_hash ?? ""),
- renderedPath: row.rendered_path,
- body: readFileIfExists(row.rendered_path),
- createdAt: row.created_at
- };
- },
-
- listEventsSince(args: ListPackEventsSinceArgs): PackEvent[] {
- const packKey = args.packKey.trim();
- if (!packKey) throw new Error("packKey is required");
- const sinceIso = args.sinceIso.trim();
- if (!sinceIso) throw new Error("sinceIso is required");
- const limit = typeof args.limit === "number" ? Math.max(1, Math.min(500, Math.floor(args.limit))) : 200;
-
- const rows = db.all<{
- id: string;
- pack_key: string;
- event_type: string;
- payload_json: string | null;
- created_at: string;
- }>(
- `
- select id, pack_key, event_type, payload_json, created_at
- from pack_events
- where project_id = ?
- and pack_key = ?
- and created_at > ?
- order by created_at asc
- limit ?
- `,
- [projectId, packKey, sinceIso, limit]
- );
-
- return rows.map((row) =>
- ensureEventMeta({
- id: row.id,
- packKey: row.pack_key,
- eventType: row.event_type,
- payload: (() => {
- try {
- return row.payload_json ? (JSON.parse(row.payload_json) as Record) : {};
- } catch {
- return {};
- }
- })(),
- createdAt: row.created_at
- })
- );
- },
-
- getHeadVersion(args: { packKey: string }): PackHeadVersion {
- const packKey = args.packKey.trim();
- if (!packKey) throw new Error("packKey is required");
- const packType = getPackIndexRow(packKey)?.pack_type ?? inferPackTypeFromKey(packKey);
- const row = db.get<{
- id: string;
- version_number: number;
- content_hash: string;
- updated_at: string;
- }>(
- `
- select v.id as id,
- v.version_number as version_number,
- v.content_hash as content_hash,
- h.updated_at as updated_at
- from pack_heads h
- join pack_versions v on v.id = h.current_version_id and v.project_id = h.project_id
- where h.project_id = ?
- and h.pack_key = ?
- limit 1
- `,
- [projectId, packKey]
- );
-
- return {
- packKey,
- packType,
- versionId: row?.id ?? null,
- versionNumber: row ? Number(row.version_number ?? 0) : null,
- contentHash: row?.content_hash != null ? String(row.content_hash) : null,
- updatedAt: row?.updated_at ?? null
- };
- },
-
- async getDeltaDigest(args: PackDeltaDigestArgs): Promise {
- const packKey = (args.packKey ?? "").trim();
- if (!packKey) throw new Error("packKey is required");
-
- const minimum = args.minimumImportance ?? "medium";
- const limit = typeof args.limit === "number" ? Math.max(10, Math.min(500, Math.floor(args.limit))) : 200;
-
- const sinceVersionId = typeof args.sinceVersionId === "string" ? args.sinceVersionId.trim() : "";
- const sinceTimestamp = typeof args.sinceTimestamp === "string" ? args.sinceTimestamp.trim() : "";
- if (!sinceVersionId && !sinceTimestamp) {
- throw new Error("sinceVersionId or sinceTimestamp is required");
- }
-
- let baselineVersion: PackVersion | null = null;
- let baselineCreatedAt: string | null = null;
- let baselineVersionId: string | null = null;
- let baselineVersionNumber: number | null = null;
- let sinceIso = sinceTimestamp;
-
- if (sinceVersionId) {
- const v = this.getVersion(sinceVersionId);
- baselineVersion = v;
- baselineCreatedAt = v.createdAt;
- baselineVersionId = v.id;
- baselineVersionNumber = v.versionNumber;
- sinceIso = v.createdAt;
- } else {
- const parsed = Date.parse(sinceTimestamp);
- if (!Number.isFinite(parsed)) throw new Error("Invalid sinceTimestamp");
- const baseline = findBaselineVersionAtOrBefore({ packKey, sinceIso: sinceTimestamp });
- if (baseline?.id) {
- const v = this.getVersion(baseline.id);
- baselineVersion = v;
- baselineCreatedAt = v.createdAt;
- baselineVersionId = v.id;
- baselineVersionNumber = v.versionNumber;
- sinceIso = v.createdAt;
- }
- }
-
- const newVersion = this.getHeadVersion({ packKey });
- const packType: PackType = newVersion.packType;
- const afterBody = newVersion.versionId ? this.getVersion(newVersion.versionId).body : "";
- const beforeBody = baselineVersion?.body ?? null;
-
- const changedSections = computeSectionChanges({
- before: beforeBody,
- after: afterBody,
- locators: getDefaultSectionLocators(packType)
- });
-
- const eventsRaw = this.listEventsSince({ packKey, sinceIso, limit });
- const highImpactEvents = eventsRaw.filter((event) => {
- const payload = (event.payload ?? {}) as Record;
- return importanceRank(payload.importance) >= importanceRank(minimum);
- });
-
- const conflictState = (() => {
- if (!packKey.startsWith("lane:")) return null;
- const laneId = packKey.slice("lane:".length);
- return deriveConflictStateForLane(laneId);
- })();
-
- const blockers: Array<{ kind: string; summary: string; entityIds?: string[] }> = [];
- if (packKey.startsWith("lane:")) {
- const laneId = packKey.slice("lane:".length);
- const row = db.get<{ parent_lane_id: string | null }>(
- "select parent_lane_id from lanes where id = ? and project_id = ? limit 1",
- [laneId, projectId]
- );
- const parentLaneId = row?.parent_lane_id ?? null;
- if (parentLaneId) {
- blockers.push({
- kind: "merge",
- summary: `Blocked by parent lane ${parentLaneId} (stacked lane).`,
- entityIds: [laneId, parentLaneId]
- });
- }
- }
- if (conflictState?.status === "conflict-active" || conflictState?.status === "conflict-predicted") {
- blockers.push({
- kind: "conflict",
- summary: `Conflicts: ${conflictState.status} (peerConflicts=${conflictState.peerConflictCount ?? 0}).`,
- entityIds: []
- });
- }
- if (conflictState?.truncated) {
- blockers.push({
- kind: "conflict",
- summary: `Conflict coverage is partial (strategy=${conflictState.strategy ?? "partial"}; pairs=${conflictState.pairwisePairsComputed ?? 0}/${conflictState.pairwisePairsTotal ?? 0}).`,
- entityIds: []
- });
- }
-
- const decisionReasons: string[] = [];
- let recommendedExportLevel: ContextExportLevel = "lite";
- if (changedSections.some((c) => c.sectionId === "narrative")) {
- recommendedExportLevel = "deep";
- decisionReasons.push("Narrative changed; deep export includes narrative content.");
- } else if (blockers.length || (conflictState?.status && conflictState.status !== "merge-ready")) {
- recommendedExportLevel = "standard";
- decisionReasons.push("Blockers/conflicts present; standard export recommended.");
- } else if (changedSections.length) {
- recommendedExportLevel = "standard";
- decisionReasons.push("Multiple sections changed; standard export recommended.");
- } else {
- decisionReasons.push("No material section changes detected; lite is sufficient.");
- }
-
- const handoffSummary = (() => {
- const parts: string[] = [];
- const baseLabel =
- baselineVersionNumber != null && newVersion.versionNumber != null
- ? `v${baselineVersionNumber} -> v${newVersion.versionNumber}`
- : `since ${sinceIso}`;
- parts.push(`${packKey} delta (${baseLabel}).`);
- if (changedSections.length) parts.push(`Changed: ${changedSections.map((c) => c.sectionId).join(", ")}.`);
- if (blockers.length) parts.push(`Blockers: ${blockers.map((b) => b.summary).join(" ")}`);
- if (highImpactEvents.length) {
- const top = highImpactEvents
- .slice(-6)
- .map((e) => `${e.eventType}${(e.payload as any)?.rationale ? ` (${String((e.payload as any).rationale)})` : ""}`);
- parts.push(`Events: ${top.join("; ")}.`);
- }
- if (conflictState?.lastPredictedAt) parts.push(`Conflicts last predicted at: ${conflictState.lastPredictedAt}.`);
- return parts.join(" ");
- })();
-
- const omittedSections: string[] = [];
- if (eventsRaw.length >= limit) {
- omittedSections.push("events:limit_cap");
- }
- if (conflictState?.truncated) {
- omittedSections.push("conflicts:partial_coverage");
- }
- const clipReason = omittedSections.length > 0 ? "budget_clipped" : null;
-
- return {
- packKey,
- packType,
- since: {
- sinceVersionId: sinceVersionId || null,
- sinceTimestamp: sinceTimestamp || sinceIso,
- baselineVersionId,
- baselineVersionNumber,
- baselineCreatedAt
- },
- newVersion,
- changedSections,
- highImpactEvents,
- blockers,
- conflicts: conflictState,
- decisionState: {
- recommendedExportLevel,
- reasons: decisionReasons
- },
- handoffSummary,
- clipReason,
- omittedSections: omittedSections.length ? omittedSections : null
- };
- },
-
- async getLaneExport(args: GetLaneExportArgs): Promise {
- const laneId = args.laneId.trim();
- if (!laneId) throw new Error("laneId is required");
- const level = args.level;
- if (level !== "lite" && level !== "standard" && level !== "deep") {
- throw new Error(`Invalid export level: ${String(level)}`);
- }
-
- const lanes = await laneService.list({ includeArchived: true });
- const lane = lanes.find((entry) => entry.id === laneId);
- if (!lane) throw new Error(`Lane not found: ${laneId}`);
-
- const pack = await buildLiveLanePackSummary({
- laneId,
- reason: "context_export"
- });
-
- const providerMode = projectConfigService.get().effective.providerMode ?? "guest";
- const { apiBaseUrl, remoteProjectId } = readGatewayMeta();
- const docsMeta = readContextDocMeta();
- const conflictRiskSummaryLines = buildLaneConflictRiskSummaryLines(laneId);
-
- const conflictState = deriveConflictStateForLane(laneId);
- const lanesById = new Map(lanes.map((l) => [l.id, l] as const));
- const lineage = computeLaneLineage({ laneId, lanesById });
-
- const requiredMerges = lane.parentLaneId ? [lane.parentLaneId] : [];
- const dependencyState: PackDependencyStateV1 = {
- requiredMerges,
- blockedByLanes: requiredMerges,
- mergeReadiness: computeMergeReadiness({
- requiredMerges,
- behindCount: lane.status.behind,
- conflictStatus: (conflictState?.status ?? null) as ConflictStatusValue | null
- })
- };
-
- const predictionPack = readConflictPredictionPack(laneId);
- const lastConflictRefreshAt =
- asString(predictionPack?.lastRecomputedAt).trim() || asString(predictionPack?.generatedAt).trim() || null;
- const lastConflictRefreshAgeMs = (() => {
- if (!lastConflictRefreshAt) return null;
- const ts = Date.parse(lastConflictRefreshAt);
- if (!Number.isFinite(ts)) return null;
- return Math.max(0, Date.now() - ts);
- })();
- const ttlMs = Number((predictionPack as any)?.stalePolicy?.ttlMs ?? NaN);
- const staleTtlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : 5 * 60_000;
- const predictionStale = lastConflictRefreshAgeMs != null ? lastConflictRefreshAgeMs > staleTtlMs : null;
- const staleReason =
- predictionStale && lastConflictRefreshAgeMs != null
- ? `lastConflictRefreshAgeMs=${lastConflictRefreshAgeMs} ttlMs=${staleTtlMs}`
- : null;
-
- const activeConflictPackKeys = (() => {
- const out: string[] = [];
- if (predictionPack?.status) out.push(`conflict:${laneId}:${lane.baseRef}`);
- const overlaps = Array.isArray(predictionPack?.overlaps) ? predictionPack!.overlaps! : [];
- const score = (v: ConflictRiskLevel) => (v === "high" ? 3 : v === "medium" ? 2 : v === "low" ? 1 : 0);
- const peers = overlaps
- .filter((ov) => ov && ov.peerId != null)
- .map((ov) => ({
- peerId: asString(ov.peerId).trim(),
- riskLevel: normalizeRiskLevel(asString(ov.riskLevel)) ?? "none",
- fileCount: Array.isArray(ov.files) ? ov.files.length : 0
- }))
- .filter((ov) => ov.peerId.length)
- .sort((a, b) => score(b.riskLevel) - score(a.riskLevel) || b.fileCount - a.fileCount || a.peerId.localeCompare(b.peerId))
- .slice(0, 6);
- for (const peer of peers) out.push(`conflict:${laneId}:${peer.peerId}`);
- return uniqueSorted(out);
- })();
-
- // --- Orchestrator summary ---
- const orchestratorSummary: OrchestratorLaneSummaryV1 = (() => {
- // Completion signal
- let completionSignal: LaneCompletionSignal = "in-progress";
- if (lane.status.ahead === 0) {
- completionSignal = "not-started";
- } else if (conflictState?.status === "conflict-active") {
- completionSignal = "blocked";
- } else if (
- lane.status.ahead > 0 &&
- !lane.status.dirty &&
- conflictState?.status === "merge-ready"
- ) {
- completionSignal = "review-ready";
- }
-
- // Touched files — extract from Key Files table in pack body
- const touchedFiles: string[] = [];
- const keyFilesRe = /\|\s*`([^`]+)`/g;
- let kfMatch: RegExpExecArray | null;
- const bodyText = pack.body ?? "";
- const keyFilesStart = bodyText.indexOf("## Key Files");
- if (keyFilesStart !== -1) {
- const keyFilesSection = bodyText.slice(keyFilesStart, bodyText.indexOf("\n## ", keyFilesStart + 1) >>> 0 || bodyText.length);
- while ((kfMatch = keyFilesRe.exec(keyFilesSection)) !== null) {
- const fp = kfMatch[1].trim();
- if (fp.length > 0 && !touchedFiles.includes(fp)) touchedFiles.push(fp);
- }
- }
-
- // Peer overlaps from prediction pack
- const peerOverlaps = (Array.isArray(predictionPack?.overlaps) ? predictionPack!.overlaps : [])
- .filter((ov: any) => ov && ov.peerId)
- .slice(0, 10)
- .map((ov: any) => ({
- peerId: String(ov.peerId ?? "").trim(),
- files: Array.isArray(ov.files) ? ov.files.map(String).slice(0, 20) : [],
- risk: (normalizeRiskLevel(String(ov.riskLevel ?? "")) ?? "none") as ConflictRiskLevel
- }))
- .filter((ov: { peerId: string }) => ov.peerId.length > 0);
-
- // Blockers
- const blockers: string[] = [];
- if (lane.status.dirty) blockers.push("dirty working tree");
- if (conflictState?.status === "conflict-active") blockers.push("active merge conflict");
- if (conflictState?.status === "conflict-predicted") blockers.push("predicted conflicts with peer lanes");
- if (lane.status.behind > 0) blockers.push(`behind base by ${lane.status.behind} commits`);
-
- return {
- laneId,
- completionSignal,
- touchedFiles: touchedFiles.slice(0, 50),
- peerOverlaps,
- suggestedMergeOrder: null,
- blockers
- };
- })();
-
- const manifest: LaneExportManifestV1 = {
- schema: "ade.manifest.lane.v1",
- projectId,
- laneId,
- laneName: lane.name,
- laneType: lane.laneType,
- worktreePath: lane.worktreePath,
- branchRef: lane.branchRef,
- baseRef: lane.baseRef,
- contextFingerprint: docsMeta.contextFingerprint,
- contextVersion: docsMeta.contextVersion,
- lastDocsRefreshAt: docsMeta.lastDocsRefreshAt,
- ...(docsMeta.docsStaleReason ? { docsStaleReason: docsMeta.docsStaleReason } : {}),
- lineage,
- mergeConstraints: {
- requiredMerges,
- blockedByLanes: requiredMerges,
- mergeReadiness: dependencyState.mergeReadiness ?? "unknown"
- },
- branchState: {
- baseRef: lane.baseRef,
- headRef: lane.branchRef,
- headSha: pack.lastHeadSha ?? null,
- lastPackRefreshAt: pack.deterministicUpdatedAt ?? null,
- isEditProtected: lane.isEditProtected,
- packStale: false
- },
- conflicts: {
- activeConflictPackKeys,
- unresolvedPairCount: conflictState?.unresolvedPairCount ?? 0,
- lastConflictRefreshAt,
- lastConflictRefreshAgeMs,
- ...(predictionPack?.truncated != null ? { truncated: Boolean(predictionPack.truncated) } : {}),
- ...(asString(predictionPack?.strategy).trim() ? { strategy: asString(predictionPack?.strategy).trim() } : {}),
- ...(Number.isFinite(Number(predictionPack?.pairwisePairsComputed)) ? { pairwisePairsComputed: Number(predictionPack?.pairwisePairsComputed) } : {}),
- ...(Number.isFinite(Number(predictionPack?.pairwisePairsTotal)) ? { pairwisePairsTotal: Number(predictionPack?.pairwisePairsTotal) } : {}),
- predictionStale,
- predictionStalenessMs: lastConflictRefreshAgeMs,
- stalePolicy: { ttlMs: staleTtlMs },
- ...(staleReason ? { staleReason } : {}),
- unresolvedResolutionState: null
- },
- orchestratorSummary
- };
-
- const graph = buildGraphEnvelope(
- [
- {
- relationType: "depends_on",
- targetPackKey: "project",
- targetPackType: "project",
- rationale: "Lane export depends on project context."
- },
- ...(lane.parentLaneId
- ? ([
- {
- relationType: "blocked_by",
- targetPackKey: `lane:${lane.parentLaneId}`,
- targetPackType: "lane",
- targetLaneId: lane.parentLaneId,
- rationale: "Stacked lane depends on parent lane landing first."
- },
- {
- relationType: "merges_into",
- targetPackKey: `lane:${lane.parentLaneId}`,
- targetPackType: "lane",
- targetLaneId: lane.parentLaneId,
- rationale: "Stacked lane merges into parent lane first."
- }
- ] satisfies PackRelation[])
- : ([
- {
- relationType: "merges_into",
- targetPackKey: `lane:${lineage.baseLaneId ?? laneId}`,
- targetPackType: "lane",
- targetLaneId: lineage.baseLaneId ?? laneId,
- rationale: "Lane merges into base lane."
- }
- ] satisfies PackRelation[]))
- ] satisfies PackRelation[]
- );
-
- return buildLaneExport({
- level,
- projectId,
- laneId,
- laneName: lane.name,
- branchRef: lane.branchRef,
- baseRef: lane.baseRef,
- headSha: pack.lastHeadSha ?? null,
- pack,
- providerMode,
- apiBaseUrl,
- remoteProjectId,
- graph,
- manifest,
- dependencyState,
- conflictState,
- markers: {
- taskSpecStart: ADE_TASK_SPEC_START,
- taskSpecEnd: ADE_TASK_SPEC_END,
- intentStart: ADE_INTENT_START,
- intentEnd: ADE_INTENT_END,
- todosStart: ADE_TODOS_START,
- todosEnd: ADE_TODOS_END,
- narrativeStart: ADE_NARRATIVE_START,
- narrativeEnd: ADE_NARRATIVE_END
- },
- conflictRiskSummaryLines
- });
- },
-
- async getProjectExport(args: GetProjectExportArgs): Promise {
- const level = args.level;
- if (level !== "lite" && level !== "standard" && level !== "deep") {
- throw new Error(`Invalid export level: ${String(level)}`);
- }
- const pack = await buildLiveProjectPackSummary({
- reason: "context_export"
- });
- const providerMode = projectConfigService.get().effective.providerMode ?? "guest";
- const { apiBaseUrl, remoteProjectId } = readGatewayMeta();
- const docsMeta = readContextDocMeta();
-
- const lanes = await laneService.list({ includeArchived: false });
- const lanesById = new Map(lanes.map((lane) => [lane.id, lane] as const));
- const lanesTotal = lanes.length;
- const maxIncluded = level === "lite" ? 10 : level === "standard" ? 25 : 80;
- const included = [...lanes]
- .filter((lane) => !lane.archivedAt)
- .sort((a, b) => a.stackDepth - b.stackDepth || a.name.localeCompare(b.name))
- .slice(0, maxIncluded);
-
- const laneEntries: ProjectManifestLaneEntryV1[] = included.map((lane) => {
- const lineage = computeLaneLineage({ laneId: lane.id, lanesById });
- const requiredMerges = lane.parentLaneId ? [lane.parentLaneId] : [];
- const conflictState = deriveConflictStateForLane(lane.id);
- const mergeReadiness = computeMergeReadiness({
- requiredMerges,
- behindCount: lane.status.behind,
- conflictStatus: (conflictState?.status ?? null) as ConflictStatusValue | null
- });
-
- return {
- laneId: lane.id,
- laneName: lane.name,
- laneType: lane.laneType,
- branchRef: lane.branchRef,
- baseRef: lane.baseRef,
- worktreePath: lane.worktreePath,
- isEditProtected: Boolean(lane.isEditProtected),
- status: lane.status,
- lineage,
- mergeConstraints: {
- requiredMerges,
- blockedByLanes: requiredMerges,
- mergeReadiness
- },
- branchState: {
- baseRef: lane.baseRef,
- headRef: lane.branchRef,
- headSha: null,
- lastPackRefreshAt: null,
- isEditProtected: lane.isEditProtected,
- packStale: false
- },
- conflictState
- };
- });
-
- const manifest: ProjectExportManifestV1 = {
- schema: "ade.manifest.project.v1",
- projectId,
- generatedAt: new Date().toISOString(),
- contextFingerprint: docsMeta.contextFingerprint,
- contextVersion: docsMeta.contextVersion,
- lastDocsRefreshAt: docsMeta.lastDocsRefreshAt,
- ...(docsMeta.docsStaleReason ? { docsStaleReason: docsMeta.docsStaleReason } : {}),
- lanesTotal,
- lanesIncluded: included.length,
- lanesOmitted: Math.max(0, lanesTotal - included.length),
- lanes: laneEntries
- };
-
- const graph = buildGraphEnvelope(
- laneEntries.map((lane) => ({
- relationType: "parent_of",
- targetPackKey: `lane:${lane.laneId}`,
- targetPackType: "lane",
- targetLaneId: lane.laneId,
- targetBranch: lane.branchRef,
- rationale: "Project contains lane context."
- })) satisfies PackRelation[]
- );
-
- return buildProjectExport({ level, projectId, pack, providerMode, apiBaseUrl, remoteProjectId, graph, manifest });
- },
-
- async getConflictExport(args: GetConflictExportArgs): Promise {
- const laneId = args.laneId.trim();
- if (!laneId) throw new Error("laneId is required");
- const peerLaneId = args.peerLaneId?.trim() || null;
- const level = args.level;
- if (level !== "lite" && level !== "standard" && level !== "deep") {
- throw new Error(`Invalid export level: ${String(level)}`);
- }
-
- const lane = laneService.getLaneBaseAndBranch(laneId);
- const peerKey = peerLaneId ?? lane.baseRef;
- const packKey = `conflict:${laneId}:${peerKey}`;
- const peerLabel = peerLaneId ? `lane:${peerLaneId}` : `base:${lane.baseRef}`;
-
- const pack = await buildLiveConflictPackSummary({
- laneId,
- peerLaneId,
- reason: "context_export"
- });
- const providerMode = projectConfigService.get().effective.providerMode ?? "guest";
- const { apiBaseUrl, remoteProjectId } = readGatewayMeta();
-
- const predictionPack = readConflictPredictionPack(laneId);
- const matrix = Array.isArray(predictionPack?.matrix) ? predictionPack!.matrix! : [];
- const entry =
- peerLaneId == null
- ? (matrix.find((m) => asString(m.laneAId).trim() === laneId && asString(m.laneBId).trim() === laneId) ?? null)
- : (matrix.find((m) => {
- const a = asString(m.laneAId).trim();
- const b = asString(m.laneBId).trim();
- return (a === laneId && b === peerLaneId) || (a === peerLaneId && b === laneId);
- }) ?? null);
-
- const ttlMs = Number((predictionPack as any)?.stalePolicy?.ttlMs ?? NaN);
- const staleTtlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : 5 * 60_000;
- const nowMs = Date.now();
- const predictionAt =
- asString((entry as any)?.computedAt).trim() ||
- asString((predictionPack as any)?.predictionAt).trim() ||
- asString((predictionPack as any)?.status?.lastPredictedAt).trim() ||
- null;
- const predictionAgeMs = (() => {
- if (!predictionAt) return null;
- const ts = Date.parse(predictionAt);
- if (!Number.isFinite(ts)) return null;
- return Math.max(0, nowMs - ts);
- })();
- const predictionStale = predictionAgeMs != null ? predictionAgeMs > staleTtlMs : null;
- const staleReason = predictionStale && predictionAgeMs != null ? `predictionAgeMs=${predictionAgeMs} ttlMs=${staleTtlMs}` : null;
-
- const openConflictSummaries = (() => {
- const raw = Array.isArray(predictionPack?.openConflictSummaries) ? predictionPack!.openConflictSummaries! : null;
- if (raw) {
- return raw
- .map((s) => {
- const riskLevel = normalizeRiskLevel(asString(s.riskLevel)) ?? "none";
- const lastSeenAt = asString(s.lastSeenAt).trim() || null;
- const lastSeenAgeMs = (() => {
- if (!lastSeenAt) return null;
- const ts = Date.parse(lastSeenAt);
- if (!Number.isFinite(ts)) return null;
- return Math.max(0, nowMs - ts);
- })();
- return {
- peerId: s.peerId ?? null,
- peerLabel: asString(s.peerLabel).trim() || "unknown",
- riskLevel,
- fileCount: Number.isFinite(Number(s.fileCount)) ? Number(s.fileCount) : 0,
- lastSeenAt,
- lastSeenAgeMs,
- riskSignals: Array.isArray(s.riskSignals) ? (s.riskSignals as string[]).map((v) => String(v)) : []
- };
- })
- .slice(0, 12);
- }
-
- const overlaps = Array.isArray(predictionPack?.overlaps) ? predictionPack!.overlaps! : [];
- const summaries: ConflictLineageV1["openConflictSummaries"] = [];
- for (const ov of overlaps) {
- const peerId = (ov.peerId ?? null) as string | null;
- const peerLabel = peerId ? `lane:${peerId}` : `base:${lane.baseRef}`;
- const riskLevel = normalizeRiskLevel(asString(ov.riskLevel)) ?? "none";
- const fileCount = Array.isArray(ov.files) ? ov.files.length : 0;
- const signals: string[] = [];
- if (riskLevel === "high") signals.push("high_risk");
- if (fileCount > 0) signals.push("overlap_files");
- if (predictionPack?.truncated) signals.push("partial_coverage");
- summaries.push({
- peerId,
- peerLabel,
- riskLevel,
- fileCount,
- lastSeenAt: null,
- lastSeenAgeMs: null,
- riskSignals: signals
- });
- }
- return summaries.slice(0, 12);
- })();
-
- const lineage: ConflictLineageV1 = {
- schema: "ade.conflictLineage.v1",
- laneId,
- peerKey,
- predictionAt,
- predictionAgeMs,
- predictionStale,
- ...(staleReason ? { staleReason } : {}),
- lastRecomputedAt:
- asString((predictionPack as any)?.lastRecomputedAt).trim() || asString((predictionPack as any)?.generatedAt).trim() || null,
- truncated: predictionPack?.truncated != null ? Boolean(predictionPack.truncated) : null,
- strategy: asString(predictionPack?.strategy).trim() || null,
- pairwisePairsComputed: Number.isFinite(Number(predictionPack?.pairwisePairsComputed)) ? Number(predictionPack?.pairwisePairsComputed) : null,
- pairwisePairsTotal: Number.isFinite(Number(predictionPack?.pairwisePairsTotal)) ? Number(predictionPack?.pairwisePairsTotal) : null,
- stalePolicy: { ttlMs: staleTtlMs },
- openConflictSummaries,
- unresolvedResolutionState: await readGitConflictState(laneId).catch(() => null)
- };
-
- const graph = buildGraphEnvelope(
- [
- {
- relationType: "depends_on",
- targetPackKey: `lane:${laneId}`,
- targetPackType: "lane",
- targetLaneId: laneId,
- targetBranch: lane.branchRef,
- targetHeadCommit: pack.lastHeadSha ?? null,
- rationale: "Conflict export depends on lane pack."
- },
- ...(peerLaneId
- ? ([
- {
- relationType: "depends_on",
- targetPackKey: `lane:${peerLaneId}`,
- targetPackType: "lane",
- targetLaneId: peerLaneId,
- rationale: "Conflict export depends on peer lane pack."
- }
- ] satisfies PackRelation[])
- : ([
- {
- relationType: "shares_base",
- targetPackKey: "project",
- targetPackType: "project",
- rationale: "Base conflicts are computed against project base ref."
- }
- ] satisfies PackRelation[]))
- ] satisfies PackRelation[]
- );
-
- return buildConflictExport({
- level,
- projectId,
- packKey,
- laneId,
- peerLabel,
- pack,
- providerMode,
- apiBaseUrl,
- remoteProjectId,
- graph,
- lineage
- });
- },
-
- async getFeatureExport(args: { featureKey: string; level: ContextExportLevel }): Promise {
- const featureKey = args.featureKey.trim();
- if (!featureKey) throw new Error("featureKey is required");
- const level = args.level;
- if (level !== "lite" && level !== "standard" && level !== "deep") {
- throw new Error(`Invalid export level: ${String(level)}`);
- }
- const packKey = `feature:${featureKey}`;
- const pack = await buildLiveFeaturePackSummary({
- featureKey,
- reason: "context_export"
- });
- return buildBasicExport({ packKey, packType: "feature", level, pack });
- },
-
- async getPlanExport(args: { laneId: string; level: ContextExportLevel }): Promise {
- const laneId = args.laneId.trim();
- if (!laneId) throw new Error("laneId is required");
- const level = args.level;
- if (level !== "lite" && level !== "standard" && level !== "deep") {
- throw new Error(`Invalid export level: ${String(level)}`);
- }
- const packKey = `plan:${laneId}`;
- const pack = await buildLivePlanPackSummary({
- laneId,
- reason: "context_export"
- });
- return buildBasicExport({ packKey, packType: "plan", level, pack });
- },
-
- async getMissionExport(args: { missionId: string; level: ContextExportLevel }): Promise {
- const missionId = args.missionId.trim();
- if (!missionId) throw new Error("missionId is required");
- const level = args.level;
- if (level !== "lite" && level !== "standard" && level !== "deep") {
- throw new Error(`Invalid export level: ${String(level)}`);
- }
- const packKey = `mission:${missionId}`;
- const pack = await buildLiveMissionPackSummary({
- missionId,
- reason: "context_export"
- });
- return buildBasicExport({ packKey, packType: "mission", level, pack });
- },
- };
-}
diff --git a/apps/desktop/src/main/services/packs/packUtils.ts b/apps/desktop/src/main/services/packs/packUtils.ts
deleted file mode 100644
index c243d011..00000000
--- a/apps/desktop/src/main/services/packs/packUtils.ts
+++ /dev/null
@@ -1,554 +0,0 @@
-/**
- * Shared utility functions for pack builders.
- *
- * These helpers are extracted from `packService.ts` to be reusable across
- * the project, lane, mission, and conflict pack builder modules.
- */
-
-import fs from "node:fs";
-import path from "node:path";
-import type { AdeDb } from "../state/kvDb";
-import type {
- ConflictRiskLevel,
- ConflictStatusValue,
- PackMergeReadiness,
- PackType,
- SessionDeltaSummary,
- TestRunStatus
-} from "../../../shared/types";
-import { safeJsonParse } from "../shared/utils";
-import type { PackGraphEnvelopeV1, PackRelation } from "../../../shared/contextContract";
-import {
- ADE_INTENT_END,
- ADE_INTENT_START,
- ADE_NARRATIVE_END,
- ADE_NARRATIVE_START,
- ADE_TASK_SPEC_END,
- ADE_TASK_SPEC_START,
- ADE_TODOS_END,
- ADE_TODOS_START
-} from "../../../shared/contextContract";
-import type { SectionLocator } from "./packSections";
-
-// ── Row types ────────────────────────────────────────────────────────────────
-
-export type LaneSessionRow = {
- id: string;
- lane_id: string;
- tracked: number;
- started_at: string;
- ended_at: string | null;
- head_sha_start: string | null;
- head_sha_end: string | null;
- transcript_path: string;
-};
-
-export type SessionDeltaRow = {
- session_id: string;
- lane_id: string;
- started_at: string;
- ended_at: string | null;
- head_sha_start: string | null;
- head_sha_end: string | null;
- files_changed: number;
- insertions: number;
- deletions: number;
- touched_files_json: string;
- failure_lines_json: string;
- computed_at: string;
-};
-
-export type ParsedNumStat = {
- insertions: number;
- deletions: number;
- files: Set;
-};
-
-// ── Conflict prediction pack shape ───────────────────────────────────────────
-
-export type ConflictPredictionPackFile = {
- laneId?: string;
- status?: {
- laneId?: string;
- status?: string;
- overlappingFileCount?: number;
- peerConflictCount?: number;
- lastPredictedAt?: string | null;
- };
- predictionAt?: string | null;
- lastRecomputedAt?: string | null;
- stalePolicy?: { ttlMs?: number };
- overlaps?: Array<{
- peerId?: string | null;
- peerName?: string;
- riskLevel?: string;
- files?: Array<{ path?: string }>;
- }>;
- openConflictSummaries?: Array<{
- peerId?: string | null;
- peerLabel?: string;
- riskLevel?: string;
- fileCount?: number;
- lastSeenAt?: string | null;
- riskSignals?: string[];
- }>;
- matrix?: Array<{
- laneAId?: string;
- laneBId?: string;
- riskLevel?: string;
- overlapCount?: number;
- hasConflict?: boolean;
- computedAt?: string | null;
- stale?: boolean;
- }>;
- generatedAt?: string;
- truncated?: boolean;
- strategy?: string;
- pairwisePairsComputed?: number;
- pairwisePairsTotal?: number;
-};
-
-// ── Pure helper functions ────────────────────────────────────────────────────
-
-export function safeJsonParseArray(raw: string | null | undefined): string[] {
- const parsed = safeJsonParse(raw, null);
- if (!Array.isArray(parsed)) return [];
- return parsed.map((entry) => String(entry));
-}
-
-export function readFileIfExists(filePath: string): string {
- try {
- return fs.readFileSync(filePath, "utf8");
- } catch {
- return "";
- }
-}
-
-export function ensureDirFor(filePath: string) {
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
-}
-
-export function ensureDir(dirPath: string) {
- fs.mkdirSync(dirPath, { recursive: true });
-}
-
-export function safeSegment(raw: string): string {
- const trimmed = raw.trim();
- if (!trimmed) return "untitled";
- return trimmed.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80);
-}
-
-export function parsePackMetadataJson(raw: string | null | undefined): Record | null {
- if (!raw || !raw.trim()) return null;
- try {
- const parsed = JSON.parse(raw) as unknown;
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
- return parsed as Record;
- } catch {
- return null;
- }
-}
-
-export function parseNumStat(stdout: string): ParsedNumStat {
- const files = new Set();
- let insertions = 0;
- let deletions = 0;
-
- const lines = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
- for (const line of lines) {
- const parts = line.split("\t");
- if (parts.length < 3) continue;
- const ins = parts[0] ?? "0";
- const del = parts[1] ?? "0";
- const filePath = parts.slice(2).join("\t").trim();
- if (filePath.length) files.add(filePath);
-
- const insNum = ins === "-" ? 0 : Number(ins);
- const delNum = del === "-" ? 0 : Number(del);
- if (Number.isFinite(insNum)) insertions += insNum;
- if (Number.isFinite(delNum)) deletions += delNum;
- }
-
- return { insertions, deletions, files };
-}
-
-export function parsePorcelainPaths(stdout: string): string[] {
- const out = new Set();
- const lines = stdout.split("\n").map((line) => line.trimEnd()).filter(Boolean);
- for (const line of lines) {
- if (line.startsWith("??")) {
- const rel = line.slice(2).trim();
- if (rel.length) out.add(rel);
- continue;
- }
-
- const raw = line.slice(2).trim();
- const arrow = raw.indexOf("->");
- if (arrow >= 0) {
- const rel = raw.slice(arrow + 2).trim();
- if (rel.length) out.add(rel);
- continue;
- }
- if (raw.length) out.add(raw);
- }
- return [...out];
-}
-
-export function parseChatTranscriptDelta(rawTranscript: string): {
- touchedFiles: string[];
- failureLines: string[];
-} {
- const touched = new Set();
- const failureLines: string[] = [];
- const seenFailure = new Set();
-
- const pushFailure = (value: string | null | undefined) => {
- const normalized = String(value ?? "").replace(/\s+/g, " ").trim();
- if (!normalized.length) return;
- const clipped = normalized.length > 320 ? normalized.slice(0, 320) : normalized;
- if (seenFailure.has(clipped)) return;
- seenFailure.add(clipped);
- failureLines.push(clipped);
- };
-
- for (const rawLine of rawTranscript.split(/\r?\n/)) {
- const line = rawLine.trim();
- if (!line.length) continue;
-
- let parsed: unknown;
- try {
- parsed = JSON.parse(line);
- } catch {
- continue;
- }
-
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
- const event = (parsed as { event?: unknown }).event;
- if (!event || typeof event !== "object" || Array.isArray(event)) continue;
- const eventRecord = event as Record;
- const type = typeof eventRecord.type === "string" ? eventRecord.type : "";
-
- if (type === "file_change") {
- const pathValue = String(eventRecord.path ?? "").trim();
- if (pathValue.length && !pathValue.startsWith("(")) touched.add(pathValue);
- continue;
- }
-
- if (type === "command") {
- const command = String(eventRecord.command ?? "").trim();
- const output = String(eventRecord.output ?? "");
- const status = String(eventRecord.status ?? "").trim();
- const exitCode = typeof eventRecord.exitCode === "number" ? eventRecord.exitCode : null;
-
- if (status === "failed" || (exitCode != null && exitCode !== 0)) {
- pushFailure(command.length ? `Command failed: ${command}` : "Command failed.");
- }
-
- for (const outputLine of output.split(/\r?\n/)) {
- const normalized = outputLine.replace(/\s+/g, " ").trim();
- if (!normalized.length) continue;
- if (!/\b(error|failed|exception|fatal|traceback)\b/i.test(normalized)) continue;
- pushFailure(normalized);
- }
- continue;
- }
-
- if (type === "error") {
- pushFailure(String(eventRecord.message ?? "Chat error."));
- continue;
- }
-
- if (type === "status") {
- const turnStatus = String(eventRecord.turnStatus ?? "").trim();
- if (turnStatus === "failed") {
- pushFailure(String(eventRecord.message ?? "Turn failed."));
- }
- }
- }
-
- return {
- touchedFiles: [...new Set(touched)].sort(),
- failureLines: failureLines.slice(-16)
- };
-}
-
-export function extractSection(existing: string, start: string, end: string, fallback: string): string {
- const startIdx = existing.indexOf(start);
- const endIdx = existing.indexOf(end);
- if (startIdx < 0 || endIdx < 0 || endIdx <= startIdx) return fallback;
- const body = existing.slice(startIdx + start.length, endIdx).trim();
- return body.length ? body : fallback;
-}
-
-export function extractSectionByHeading(existing: string, heading: string): string | null {
- const re = new RegExp(`^${heading.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*$`, "m");
- const match = re.exec(existing);
- if (!match?.index && match?.index !== 0) return null;
-
- const headingStart = match.index;
- const headingLineEnd = existing.indexOf("\n", headingStart);
- const sectionStart = headingLineEnd >= 0 ? headingLineEnd + 1 : existing.length;
-
- const nextHeading = (() => {
- const r = /^##\s+/gm;
- r.lastIndex = sectionStart;
- const m = r.exec(existing);
- return m ? m.index : -1;
- })();
- const nextHr = (() => {
- const r = /^---\s*$/gm;
- r.lastIndex = sectionStart;
- const m = r.exec(existing);
- return m ? m.index : -1;
- })();
-
- const candidates = [nextHeading, nextHr].filter((idx) => idx >= 0);
- const sectionEnd = candidates.length ? Math.min(...candidates) : existing.length;
-
- const body = existing.slice(sectionStart, sectionEnd).trim();
- return body.length ? body : "";
-}
-
-export function statusFromCode(status: TestRunStatus): string {
- if (status === "passed") return "PASS";
- if (status === "failed") return "FAIL";
- if (status === "running") return "RUNNING";
- if (status === "canceled") return "CANCELED";
- return "TIMED_OUT";
-}
-
-export function humanToolLabel(toolType: string | null | undefined): string {
- const normalized = String(toolType ?? "").trim().toLowerCase();
- if (!normalized) return "Shell";
- if (normalized === "claude") return "Claude";
- if (normalized === "codex") return "Codex";
- if (normalized === "cursor") return "Cursor";
- if (normalized === "aider") return "Aider";
- if (normalized === "continue") return "Continue";
- if (normalized === "shell") return "Shell";
- return normalized.slice(0, 1).toUpperCase() + normalized.slice(1);
-}
-
-export function shellQuoteArg(arg: string): string {
- const value = String(arg);
- if (!value.length) return "''";
- if (/^[a-zA-Z0-9_./:-]+$/.test(value)) return value;
- return `'${value.replace(/'/g, `'\"'\"'`)}'`;
-}
-
-export function formatCommand(command: unknown): string {
- if (Array.isArray(command)) return command.map((part) => shellQuoteArg(String(part))).join(" ");
- if (typeof command === "string") return command.trim();
- return JSON.stringify(command);
-}
-
-export function moduleFromPath(relPath: string): string {
- const normalized = relPath.replace(/\\/g, "/");
- const first = normalized.split("/")[0] ?? normalized;
- return first || ".";
-}
-
-export function normalizeConflictStatus(value: string): ConflictStatusValue | null {
- const v = value.trim();
- if (
- v === "merge-ready" ||
- v === "behind-base" ||
- v === "conflict-predicted" ||
- v === "conflict-active" ||
- v === "unknown"
- ) {
- return v;
- }
- return null;
-}
-
-export function normalizeRiskLevel(value: string): ConflictRiskLevel | null {
- const v = value.trim();
- if (v === "none" || v === "low" || v === "medium" || v === "high") return v;
- return null;
-}
-
-export function isRecord(value: unknown): value is Record {
- return !!value && typeof value === "object" && !Array.isArray(value);
-}
-
-export function asString(value: unknown): string {
- return typeof value === "string" ? value : "";
-}
-
-export function parseRecord(raw: string | null | undefined): Record | null {
- if (!raw) return null;
- try {
- const parsed = JSON.parse(raw);
- return isRecord(parsed) ? parsed : null;
- } catch {
- return null;
- }
-}
-
-export function computeMergeReadiness(args: {
- requiredMerges: string[];
- behindCount: number;
- conflictStatus: ConflictStatusValue | null;
-}): PackMergeReadiness {
- if (args.requiredMerges.length) return "blocked";
- if (args.conflictStatus === "unknown" || args.conflictStatus == null) return "unknown";
- if (args.conflictStatus === "conflict-active" || args.conflictStatus === "conflict-predicted") return "blocked";
- if (args.behindCount > 0) return "needs_sync";
- return "ready";
-}
-
-export function buildGraphEnvelope(relations: PackRelation[]): PackGraphEnvelopeV1 {
- return {
- schema: "ade.packGraph.v1",
- relations
- };
-}
-
-export function importanceRank(value: unknown): number {
- const v = typeof value === "string" ? value.trim().toLowerCase() : "";
- if (v === "high") return 3;
- if (v === "medium") return 2;
- if (v === "low") return 1;
- return 0;
-}
-
-export function getDefaultSectionLocators(packType: PackType): SectionLocator[] {
- if (packType === "lane") {
- return [
- { id: "task_spec", kind: "markers", startMarker: ADE_TASK_SPEC_START, endMarker: ADE_TASK_SPEC_END },
- { id: "intent", kind: "markers", startMarker: ADE_INTENT_START, endMarker: ADE_INTENT_END },
- { id: "todos", kind: "markers", startMarker: ADE_TODOS_START, endMarker: ADE_TODOS_END },
- { id: "narrative", kind: "markers", startMarker: ADE_NARRATIVE_START, endMarker: ADE_NARRATIVE_END },
- { id: "what_changed", kind: "heading", heading: "## What Changed" },
- { id: "validation", kind: "heading", heading: "## Validation" },
- { id: "errors", kind: "heading", heading: "## Errors & Issues" },
- { id: "sessions", kind: "heading", heading: "## Sessions" }
- ];
- }
- if (packType === "conflict") {
- return [
- { id: "overlap", kind: "heading", heading: "## Overlapping Files" },
- { id: "conflicts", kind: "heading", heading: "## Conflicts (merge-tree)" },
- { id: "lane_excerpt", kind: "heading", heading: "## Lane Pack (Excerpt)" }
- ];
- }
- return [
- { id: "bootstrap", kind: "heading", heading: "## Bootstrap context (codebase + docs)" },
- { id: "lane_snapshot", kind: "heading", heading: "## Lane Snapshot" }
- ];
-}
-
-export function rowToSessionDelta(row: SessionDeltaRow): SessionDeltaSummary {
- return {
- sessionId: row.session_id,
- laneId: row.lane_id,
- startedAt: row.started_at,
- endedAt: row.ended_at,
- headShaStart: row.head_sha_start,
- headShaEnd: row.head_sha_end,
- filesChanged: Number(row.files_changed ?? 0),
- insertions: Number(row.insertions ?? 0),
- deletions: Number(row.deletions ?? 0),
- touchedFiles: safeJsonParseArray(row.touched_files_json),
- failureLines: safeJsonParseArray(row.failure_lines_json),
- computedAt: row.computed_at ?? null
- };
-}
-
-/**
- * Upsert a row into `packs_index`.
- */
-export function upsertPackIndex({
- db,
- projectId,
- packKey,
- laneId,
- packType,
- packPath,
- deterministicUpdatedAt,
- narrativeUpdatedAt,
- lastHeadSha,
- metadata
-}: {
- db: AdeDb;
- projectId: string;
- packKey: string;
- laneId: string | null;
- packType: PackType;
- packPath: string;
- deterministicUpdatedAt: string;
- narrativeUpdatedAt?: string | null;
- lastHeadSha?: string | null;
- metadata?: Record;
-}) {
- db.run(
- `
- insert into packs_index(
- pack_key,
- project_id,
- lane_id,
- pack_type,
- pack_path,
- deterministic_updated_at,
- narrative_updated_at,
- last_head_sha,
- metadata_json
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
- on conflict(pack_key) do update set
- project_id = excluded.project_id,
- lane_id = excluded.lane_id,
- pack_type = excluded.pack_type,
- pack_path = excluded.pack_path,
- deterministic_updated_at = excluded.deterministic_updated_at,
- narrative_updated_at = excluded.narrative_updated_at,
- last_head_sha = excluded.last_head_sha,
- metadata_json = excluded.metadata_json
- `,
- [
- packKey,
- projectId,
- laneId,
- packType,
- packPath,
- deterministicUpdatedAt,
- narrativeUpdatedAt ?? null,
- lastHeadSha ?? null,
- JSON.stringify(metadata ?? {})
- ]
- );
-}
-
-export function toPackSummaryFromRow(args: {
- packKey: string;
- row: {
- pack_type: PackType;
- pack_path: string;
- deterministic_updated_at: string | null;
- narrative_updated_at: string | null;
- last_head_sha: string | null;
- metadata_json?: string | null;
- } | null;
- version: { versionId: string; versionNumber: number; contentHash: string } | null;
-}) {
- const packType = args.row?.pack_type ?? "project";
- const packPath = args.row?.pack_path ?? "";
- const body = packPath ? readFileIfExists(packPath) : "";
- const exists = packPath.length ? fs.existsSync(packPath) : false;
- const metadata = parsePackMetadataJson(args.row?.metadata_json);
-
- return {
- packKey: args.packKey,
- packType,
- path: packPath,
- exists,
- deterministicUpdatedAt: args.row?.deterministic_updated_at ?? null,
- narrativeUpdatedAt: args.row?.narrative_updated_at ?? null,
- lastHeadSha: args.row?.last_head_sha ?? null,
- versionId: args.version?.versionId ?? null,
- versionNumber: args.version?.versionNumber ?? null,
- contentHash: args.version?.contentHash ?? null,
- metadata,
- body
- };
-}
diff --git a/apps/desktop/src/main/services/packs/transcriptInsights.test.ts b/apps/desktop/src/main/services/packs/transcriptInsights.test.ts
deleted file mode 100644
index c4082a50..00000000
--- a/apps/desktop/src/main/services/packs/transcriptInsights.test.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { deriveSessionSummaryFromText, inferTestOutcomeFromText, parseTranscriptSummary } from "./transcriptInsights";
-
-describe("transcriptInsights", () => {
- it("derives a compact summary from Claude-style output", () => {
- const raw = [
- "Some earlier log line",
- "",
- "All 4 tests pass. Here's what I added:",
- "",
- " Tip calculation on receipts — the receipt now includes a tip line and a total.",
- " It defaults to 18% but accepts a custom tipPercent via the options.",
- "",
- "✻ Cooked for 41s",
- ""
- ].join("\n");
-
- const summary = deriveSessionSummaryFromText(raw);
- expect(summary).toContain("All 4 tests pass");
- expect(summary).toContain("Tip calculation on receipts");
- expect(summary.toLowerCase()).not.toContain("cooked for");
- });
-
- it("prefers explicit final blocks and records source/confidence", () => {
- const raw = [
- "Checking tests...",
- "",
- "Done. Here's what changed:",
- "- Updated `src/main.ts` to normalize args",
- "- Added retry guard in src/services/retry.ts",
- "- Wrote docs in docs/features/PACKS.md"
- ].join("\n");
-
- const parsed = parseTranscriptSummary(raw);
- expect(parsed).toBeTruthy();
- expect(parsed?.source).toBe("explicit_final_block");
- expect(parsed?.confidence).toBe("high");
- expect(parsed?.files).toContain("docs/features/PACKS.md");
- expect(parsed?.files).toContain("src/main.ts");
- });
-
- it("recognizes worker closeout phrasing used by live missions", () => {
- const raw = [
- "thinking",
- "Accomplished: verified the Test tab feature is already fully implemented and committed on this lane branch; no source code changes were required.",
- "",
- "What I verified:",
- "- [TabNav.tsx](/tmp/TabNav.tsx) includes `Test` with `Flask` and `/test`.",
- "- [TestPage.tsx](/tmp/TestPage.tsx) exists and renders 'Coming Soon'.",
- ].join("\n");
-
- const parsed = parseTranscriptSummary(raw);
- expect(parsed?.source).toBe("explicit_final_block");
- expect(parsed?.summary).toContain("Accomplished:");
- expect(parsed?.files.some((file) => file.endsWith("TabNav.tsx"))).toBe(true);
- });
-
- it("recognizes planning-worker closeout phrasing", () => {
- const raw = [
- "noise",
- "Research is complete. The \"Test\" tab with \"Coming Soon\" screen is already fully implemented in the codebase from a previous mission run.",
- "",
- "No code changes are needed to complete this task.",
- ].join("\n");
-
- const summary = deriveSessionSummaryFromText(raw);
- expect(summary).toContain("Research is complete.");
- expect(summary).toContain("No code changes are needed");
- });
-
- it("falls back to heuristic tail summary when no explicit block exists", () => {
- const raw = [
- "starting",
- "ran tests",
- "All 12 tests passed in 3.8s",
- "Updated parser and conflict heuristics"
- ].join("\n");
- const parsed = parseTranscriptSummary(raw);
- expect(parsed).toBeTruthy();
- expect(parsed?.source).toBe("heuristic_tail");
- expect(parsed?.confidence).toBe("medium");
- });
-
- it("infers test pass from an 'All tests pass' line", () => {
- const inferred = inferTestOutcomeFromText("All 4 tests pass.\n");
- expect(inferred?.status).toBe("pass");
- expect(inferred?.evidence).toContain("All 4 tests pass");
- });
-
- it("infers test fail from a jest-like summary line", () => {
- const inferred = inferTestOutcomeFromText("Test Suites: 1 failed, 3 passed, 4 total\n");
- expect(inferred?.status).toBe("fail");
- expect(inferred?.evidence).toContain("Test Suites:");
- });
-});
diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts
index a85db834..298785e1 100644
--- a/apps/desktop/src/main/services/prs/prService.ts
+++ b/apps/desktop/src/main/services/prs/prService.ts
@@ -118,6 +118,17 @@ type PullRequestRow = {
updated_at: string;
};
+type PullRequestSnapshotHydration = {
+ prId: string;
+ detail: PrDetail | null;
+ status: PrStatus | null;
+ checks: PrCheck[];
+ reviews: PrReview[];
+ comments: PrComment[];
+ files: PrFile[];
+ updatedAt: string | null;
+};
+
type IntegrationProposalRow = {
id: string;
source_lane_ids_json: string;
@@ -630,7 +641,9 @@ export function createPrService({
[args.prId],
);
- const encode = (next: unknown, fallback: string | null | undefined): string | null => {
+ // If the caller provides a value, serialize it. If undefined, keep the
+ // existing DB value. If explicitly null, store null.
+ const jsonOrFallback = (next: unknown, fallback: string | null | undefined): string | null => {
if (next === undefined) return fallback ?? null;
if (next == null) return null;
return JSON.stringify(next);
@@ -652,17 +665,59 @@ export function createPrService({
`,
[
args.prId,
- encode(args.detail, existing?.detail_json),
- encode(args.status, existing?.status_json),
- encode(args.checks, existing?.checks_json),
- encode(args.reviews, existing?.reviews_json),
- encode(args.comments, existing?.comments_json),
- encode(args.files, existing?.files_json),
+ jsonOrFallback(args.detail, existing?.detail_json),
+ jsonOrFallback(args.status, existing?.status_json),
+ jsonOrFallback(args.checks, existing?.checks_json),
+ jsonOrFallback(args.reviews, existing?.reviews_json),
+ jsonOrFallback(args.comments, existing?.comments_json),
+ jsonOrFallback(args.files, existing?.files_json),
args.updatedAt ?? nowIso(),
],
);
};
+ const decodeSnapshotJson = (raw: string | null): T | null => {
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+ };
+
+ const listSnapshotRows = (args: { prId?: string } = {}): PullRequestSnapshotHydration[] => {
+ const rows = db.all<{
+ pr_id: string;
+ detail_json: string | null;
+ status_json: string | null;
+ checks_json: string | null;
+ reviews_json: string | null;
+ comments_json: string | null;
+ files_json: string | null;
+ updated_at: string | null;
+ }>(
+ `
+ select s.pr_id, s.detail_json, s.status_json, s.checks_json, s.reviews_json, s.comments_json, s.files_json, s.updated_at
+ from pull_request_snapshots s
+ join pull_requests p on p.id = s.pr_id and p.project_id = ?
+ ${args.prId ? "where s.pr_id = ?" : ""}
+ order by p.updated_at desc
+ `,
+ args.prId ? [projectId, args.prId] : [projectId],
+ );
+
+ return rows.map((row) => ({
+ prId: row.pr_id,
+ detail: decodeSnapshotJson(row.detail_json),
+ status: decodeSnapshotJson(row.status_json),
+ checks: decodeSnapshotJson(row.checks_json) ?? [],
+ reviews: decodeSnapshotJson(row.reviews_json) ?? [],
+ comments: decodeSnapshotJson(row.comments_json) ?? [],
+ files: decodeSnapshotJson(row.files_json) ?? [],
+ updatedAt: row.updated_at,
+ }));
+ };
+
const upsertRow = (summary: Omit & { projectId?: string }): void => {
const now = nowIso();
const existing = getRowForLane(summary.laneId);
@@ -3735,6 +3790,10 @@ export function createPrService({
return { refreshedCount: rows.length };
},
+ listSnapshots(args: { prId?: string } = {}): PullRequestSnapshotHydration[] {
+ return listSnapshotRows(args);
+ },
+
// ------------------------------------------------------------------
// PR Detail Overhaul Methods
// ------------------------------------------------------------------
diff --git a/apps/desktop/src/main/services/sessions/sessionDeltaService.ts b/apps/desktop/src/main/services/sessions/sessionDeltaService.ts
index 75299f9b..12c31314 100644
--- a/apps/desktop/src/main/services/sessions/sessionDeltaService.ts
+++ b/apps/desktop/src/main/services/sessions/sessionDeltaService.ts
@@ -6,7 +6,7 @@ import {
parseNumStat,
parsePorcelainPaths,
rowToSessionDelta,
-} from "../packs/packUtils";
+} from "../shared/packLegacyUtils";
import type { createSessionService } from "./sessionService";
import { runGit } from "../git/git";
import type { AdeDb } from "../state/kvDb";
diff --git a/apps/desktop/src/main/services/shared/packLegacyUtils.ts b/apps/desktop/src/main/services/shared/packLegacyUtils.ts
new file mode 100644
index 00000000..07cb3642
--- /dev/null
+++ b/apps/desktop/src/main/services/shared/packLegacyUtils.ts
@@ -0,0 +1,234 @@
+/**
+ * Utility functions formerly in packs/packUtils.ts, retained because they are
+ * consumed by sessionDeltaService, conflictService, and other non-pack code.
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+import type { SessionDeltaSummary } from "../../../shared/types";
+import { safeJsonParse } from "./utils";
+
+// ── Row types ────────────────────────────────────────────────────────────────
+
+export type LaneSessionRow = {
+ id: string;
+ lane_id: string;
+ tracked: number;
+ started_at: string;
+ ended_at: string | null;
+ head_sha_start: string | null;
+ head_sha_end: string | null;
+ transcript_path: string;
+};
+
+export type SessionDeltaRow = {
+ session_id: string;
+ lane_id: string;
+ started_at: string;
+ ended_at: string | null;
+ head_sha_start: string | null;
+ head_sha_end: string | null;
+ files_changed: number;
+ insertions: number;
+ deletions: number;
+ touched_files_json: string;
+ failure_lines_json: string;
+ computed_at: string;
+};
+
+export type ParsedNumStat = {
+ insertions: number;
+ deletions: number;
+ files: Set;
+};
+
+// ── Pure helper functions ────────────────────────────────────────────────────
+
+export function safeJsonParseArray(raw: string | null | undefined): string[] {
+ const parsed = safeJsonParse(raw, null);
+ if (!Array.isArray(parsed)) return [];
+ return parsed.map((entry) => String(entry));
+}
+
+export function readFileIfExists(filePath: string): string {
+ try {
+ return fs.readFileSync(filePath, "utf8");
+ } catch {
+ return "";
+ }
+}
+
+export function ensureDirFor(filePath: string) {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+}
+
+export function ensureDir(dirPath: string) {
+ fs.mkdirSync(dirPath, { recursive: true });
+}
+
+export function safeSegment(raw: string): string {
+ const trimmed = raw.trim();
+ if (!trimmed) return "untitled";
+ return trimmed.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80);
+}
+
+export function parseNumStat(stdout: string): ParsedNumStat {
+ const files = new Set();
+ let insertions = 0;
+ let deletions = 0;
+
+ const lines = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
+ for (const line of lines) {
+ const parts = line.split("\t");
+ if (parts.length < 3) continue;
+ const ins = parts[0] ?? "0";
+ const del = parts[1] ?? "0";
+ const filePath = parts.slice(2).join("\t").trim();
+ if (filePath.length) files.add(filePath);
+
+ const insNum = ins === "-" ? 0 : Number(ins);
+ const delNum = del === "-" ? 0 : Number(del);
+ if (Number.isFinite(insNum)) insertions += insNum;
+ if (Number.isFinite(delNum)) deletions += delNum;
+ }
+
+ return { insertions, deletions, files };
+}
+
+export function parsePorcelainPaths(stdout: string): string[] {
+ const out = new Set();
+ const lines = stdout.split("\n").map((line) => line.trimEnd()).filter(Boolean);
+ for (const line of lines) {
+ if (line.startsWith("??")) {
+ const rel = line.slice(2).trim();
+ if (rel.length) out.add(rel);
+ continue;
+ }
+
+ const raw = line.slice(2).trim();
+ const arrow = raw.indexOf("->");
+ if (arrow >= 0) {
+ const rel = raw.slice(arrow + 2).trim();
+ if (rel.length) out.add(rel);
+ continue;
+ }
+ if (raw.length) out.add(raw);
+ }
+ return [...out];
+}
+
+export function parseChatTranscriptDelta(rawTranscript: string): {
+ touchedFiles: string[];
+ failureLines: string[];
+} {
+ const touched = new Set();
+ const failureLines: string[] = [];
+ const seenFailure = new Set();
+
+ const pushFailure = (value: string | null | undefined) => {
+ const normalized = String(value ?? "").replace(/\s+/g, " ").trim();
+ if (!normalized.length) return;
+ const clipped = normalized.length > 320 ? normalized.slice(0, 320) : normalized;
+ if (seenFailure.has(clipped)) return;
+ seenFailure.add(clipped);
+ failureLines.push(clipped);
+ };
+
+ for (const rawLine of rawTranscript.split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line.length) continue;
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(line);
+ } catch {
+ continue;
+ }
+
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
+ const event = (parsed as { event?: unknown }).event;
+ if (!event || typeof event !== "object" || Array.isArray(event)) continue;
+ const eventRecord = event as Record;
+ const type = typeof eventRecord.type === "string" ? eventRecord.type : "";
+
+ if (type === "file_change") {
+ const pathValue = String(eventRecord.path ?? "").trim();
+ if (pathValue.length && !pathValue.startsWith("(")) touched.add(pathValue);
+ continue;
+ }
+
+ if (type === "command") {
+ const command = String(eventRecord.command ?? "").trim();
+ const output = String(eventRecord.output ?? "");
+ const status = String(eventRecord.status ?? "").trim();
+ const exitCode = typeof eventRecord.exitCode === "number" ? eventRecord.exitCode : null;
+
+ if (status === "failed" || (exitCode != null && exitCode !== 0)) {
+ pushFailure(command.length ? `Command failed: ${command}` : "Command failed.");
+ }
+
+ for (const outputLine of output.split(/\r?\n/)) {
+ const normalized = outputLine.replace(/\s+/g, " ").trim();
+ if (!normalized.length) continue;
+ if (!/\b(error|failed|exception|fatal|traceback)\b/i.test(normalized)) continue;
+ pushFailure(normalized);
+ }
+ continue;
+ }
+
+ if (type === "error") {
+ pushFailure(String(eventRecord.message ?? "Chat error."));
+ continue;
+ }
+
+ if (type === "status") {
+ const turnStatus = String(eventRecord.turnStatus ?? "").trim();
+ if (turnStatus === "failed") {
+ pushFailure(String(eventRecord.message ?? "Turn failed."));
+ }
+ }
+ }
+
+ return {
+ touchedFiles: [...new Set(touched)].sort(),
+ failureLines: failureLines.slice(-16)
+ };
+}
+
+export function formatCommand(command: unknown): string {
+ if (Array.isArray(command)) {
+ return command.map((part) => {
+ const value = String(part);
+ if (!value.length) return "''";
+ if (/^[a-zA-Z0-9_./:-]+$/.test(value)) return value;
+ return `'${value.replace(/'/g, `'\"'\"'`)}'`;
+ }).join(" ");
+ }
+ if (typeof command === "string") return command.trim();
+ return JSON.stringify(command);
+}
+
+export function isRecord(value: unknown): value is Record {
+ return !!value && typeof value === "object" && !Array.isArray(value);
+}
+
+export function asString(value: unknown): string {
+ return typeof value === "string" ? value : "";
+}
+
+export function rowToSessionDelta(row: SessionDeltaRow): SessionDeltaSummary {
+ return {
+ sessionId: row.session_id,
+ laneId: row.lane_id,
+ startedAt: row.started_at,
+ endedAt: row.ended_at,
+ headShaStart: row.head_sha_start,
+ headShaEnd: row.head_sha_end,
+ filesChanged: Number(row.files_changed ?? 0),
+ insertions: Number(row.insertions ?? 0),
+ deletions: Number(row.deletions ?? 0),
+ touchedFiles: safeJsonParseArray(row.touched_files_json),
+ failureLines: safeJsonParseArray(row.failure_lines_json),
+ computedAt: row.computed_at ?? null
+ };
+}
diff --git a/apps/desktop/src/main/services/packs/transcriptInsights.ts b/apps/desktop/src/main/services/shared/transcriptInsights.ts
similarity index 95%
rename from apps/desktop/src/main/services/packs/transcriptInsights.ts
rename to apps/desktop/src/main/services/shared/transcriptInsights.ts
index a157d274..e6f0af2e 100644
--- a/apps/desktop/src/main/services/packs/transcriptInsights.ts
+++ b/apps/desktop/src/main/services/shared/transcriptInsights.ts
@@ -1,3 +1,8 @@
+/**
+ * Transcript insight extraction utilities.
+ * Relocated from packs/transcriptInsights.ts after pack system removal.
+ */
+
import { stripAnsi } from "../../utils/ansiStrip";
export type InferredTestOutcome = {
@@ -50,7 +55,7 @@ function lineAt(text: string, idx: number): string {
function isNoiseLine(line: string): boolean {
const t = line.trim();
if (!t) return true;
- if (t.startsWith("✻")) return true;
+ if (t.startsWith("\u2733")) return true;
const lower = t.toLowerCase();
if (lower.startsWith("cooked for")) return true;
return false;
@@ -104,8 +109,8 @@ function parseExplicitFinalBlock(text: string): ParsedTranscriptSummary | null {
if (!block.length) return null;
const bullets = block
- .filter((line) => /^[-*•]\s+/.test(line) || /^\d+\.\s+/.test(line))
- .map((line) => line.replace(/^[-*•]\s+/, "").replace(/^\d+\.\s+/, "").trim())
+ .filter((line) => /^[-*\u2022]\s+/.test(line) || /^\d+\.\s+/.test(line))
+ .map((line) => line.replace(/^[-*\u2022]\s+/, "").replace(/^\d+\.\s+/, "").trim())
.filter(Boolean);
const summary = block
diff --git a/apps/desktop/src/main/services/state/crsqliteExtension.ts b/apps/desktop/src/main/services/state/crsqliteExtension.ts
index a5b01195..a2984278 100644
--- a/apps/desktop/src/main/services/state/crsqliteExtension.ts
+++ b/apps/desktop/src/main/services/state/crsqliteExtension.ts
@@ -15,7 +15,12 @@ function platformArchDir(): string {
return `${process.platform}-${process.arch}`;
}
-export function resolveCrsqliteExtensionPath(): string {
+let cachedCrsqlitePath: string | null | undefined;
+
+export function resolveCrsqliteExtensionPath(): string | null {
+ if (cachedCrsqlitePath !== undefined) {
+ return cachedCrsqlitePath;
+ }
const relativePath = path.join("vendor", "crsqlite", platformArchDir(), extensionFileName());
const candidates = [
process.resourcesPath ? path.join(process.resourcesPath, "app.asar.unpacked", relativePath) : null,
@@ -28,9 +33,15 @@ export function resolveCrsqliteExtensionPath(): string {
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
- return candidate;
+ cachedCrsqlitePath = candidate;
+ return cachedCrsqlitePath;
}
}
- throw new Error(`Unable to locate cr-sqlite extension for ${platformArchDir()}`);
+ cachedCrsqlitePath = null;
+ return cachedCrsqlitePath;
+}
+
+export function isCrsqliteAvailable(): boolean {
+ return resolveCrsqliteExtensionPath() != null;
}
diff --git a/apps/desktop/src/main/services/state/kvDb.sync.test.ts b/apps/desktop/src/main/services/state/kvDb.sync.test.ts
index e6c02036..83cc552f 100644
--- a/apps/desktop/src/main/services/state/kvDb.sync.test.ts
+++ b/apps/desktop/src/main/services/state/kvDb.sync.test.ts
@@ -4,6 +4,7 @@ import path from "node:path";
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
import { openKvDb } from "./kvDb";
+import { isCrsqliteAvailable } from "./crsqliteExtension";
const require = createRequire(import.meta.url);
@@ -21,7 +22,7 @@ function makeDbPath(prefix: string): string {
return path.join(root, ".ade", "kv.sqlite");
}
-describe("kvDb sync foundation", () => {
+describe.skipIf(!isCrsqliteAvailable())("kvDb sync foundation", () => {
it("persists a stable local site id and marks CRR tables", async () => {
const dbPath = makeDbPath("ade-kvdb-sync-site-");
const db = await openKvDb(dbPath, createLogger() as any);
@@ -259,11 +260,28 @@ describe("kvDb sync foundation", () => {
const result = db2.sync.applyChanges(db1.sync.exportChangesSince(0));
expect(result.rebuiltFts).toBe(true);
- const match = db2.get<{ count: number }>(
- "select count(*) as count from unified_memories_fts where unified_memories_fts match ?",
- ["ios"]
+ // FTS4/5 may not be available (e.g. node:sqlite without fts modules).
+ // When unavailable, the fallback plain table is used — verify with LIKE.
+ const isFts = Boolean(
+ db2.get<{ type: string }>(
+ "select type from sqlite_master where name = 'unified_memories_fts' and type = 'table' limit 1"
+ )
);
- expect(Number(match?.count ?? 0)).toBeGreaterThan(0);
+ if (isFts) {
+ // Real FTS virtual table — query with MATCH
+ const match = db2.get<{ count: number }>(
+ "select count(*) as count from unified_memories_fts where unified_memories_fts match ?",
+ ["ios"]
+ );
+ expect(Number(match?.count ?? 0)).toBeGreaterThan(0);
+ } else {
+ // Fallback plain table — verify content was copied with LIKE
+ const match = db2.get<{ count: number }>(
+ "select count(*) as count from unified_memories_fts where content like ?",
+ ["%ios%"]
+ );
+ expect(Number(match?.count ?? 0)).toBeGreaterThan(0);
+ }
db1.close();
db2.close();
diff --git a/apps/desktop/src/main/services/state/kvDb.test.ts b/apps/desktop/src/main/services/state/kvDb.test.ts
new file mode 100644
index 00000000..9d06a040
--- /dev/null
+++ b/apps/desktop/src/main/services/state/kvDb.test.ts
@@ -0,0 +1,250 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { openKvDb } from "./kvDb";
+import { isCrsqliteAvailable } from "./crsqliteExtension";
+
+function createLogger() {
+ return {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ } as const;
+}
+
+function makeProjectRoot(prefix: string): string {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
+ fs.mkdirSync(path.join(root, ".ade", "artifacts"), { recursive: true });
+ return root;
+}
+
+function insertProjectGraph(db: Awaited>) {
+ const now = "2026-03-17T00:00:00.000Z";
+ db.run(
+ `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at)
+ values (?, ?, ?, ?, ?, ?)`,
+ ["project-1", "/repo/ade", "ADE", "main", now, now],
+ );
+ db.run(
+ `insert into lanes(
+ id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path,
+ is_edit_protected, parent_lane_id, color, icon, tags_json, folder, status, created_at, archived_at
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ "lane-primary",
+ "project-1",
+ "Primary",
+ null,
+ "primary",
+ "main",
+ "main",
+ "/repo/ade",
+ null,
+ 1,
+ null,
+ null,
+ null,
+ null,
+ null,
+ "active",
+ now,
+ null,
+ ],
+ );
+ db.run(
+ `insert into lanes(
+ id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path,
+ is_edit_protected, parent_lane_id, color, icon, tags_json, folder, status, created_at, archived_at
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ "lane-child",
+ "project-1",
+ "linear test",
+ null,
+ "worktree",
+ "main",
+ "ade/linear-test",
+ "/repo/ade/.ade/worktrees/linear-test",
+ null,
+ 0,
+ "lane-primary",
+ null,
+ null,
+ null,
+ null,
+ "active",
+ "2026-03-17T00:05:00.000Z",
+ null,
+ ],
+ );
+ db.run(
+ `insert into lane_state_snapshots(
+ lane_id, dirty, ahead, behind, remote_behind, rebase_in_progress, agent_summary_json, mission_summary_json, updated_at
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ ["lane-primary", 0, 0, 0, 0, 0, null, null, now],
+ );
+ db.run(
+ `insert into lane_state_snapshots(
+ lane_id, dirty, ahead, behind, remote_behind, rebase_in_progress, agent_summary_json, mission_summary_json, updated_at
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ ["lane-child", 1, 0, 1, 0, 0, null, null, "2026-03-17T00:05:00.000Z"],
+ );
+}
+
+function insertSessionAndPr(db: Awaited>) {
+ const now = "2026-03-17T00:10:00.000Z";
+ db.run(
+ `insert into terminal_sessions(
+ id, lane_id, pty_id, tracked, goal, tool_type, pinned, title, started_at, ended_at,
+ exit_code, transcript_path, head_sha_start, head_sha_end, status, last_output_preview,
+ last_output_at, summary, resume_command
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ "session-1",
+ "lane-child",
+ null,
+ 1,
+ "Ship W5",
+ "run-shell",
+ 0,
+ "npm test",
+ now,
+ null,
+ null,
+ "/tmp/session-1.log",
+ null,
+ null,
+ "running",
+ "Tests starting",
+ now,
+ null,
+ "npm test",
+ ],
+ );
+ db.run(
+ `insert into pull_requests(
+ id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id,
+ title, state, base_branch, head_branch, checks_status, review_status, additions, deletions,
+ last_synced_at, created_at, updated_at
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ "pr-1",
+ "project-1",
+ "lane-child",
+ "arul",
+ "ade",
+ 42,
+ "https://github.com/arul/ade/pull/42",
+ "node-42",
+ "Fix mobile hydration",
+ "open",
+ "main",
+ "ade/linear-test",
+ "pending",
+ "requested",
+ 12,
+ 4,
+ now,
+ now,
+ now,
+ ],
+ );
+ db.run(
+ `insert into pull_request_snapshots(
+ pr_id, detail_json, status_json, checks_json, reviews_json, comments_json, files_json, updated_at
+ ) values (?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ "pr-1",
+ JSON.stringify({
+ prId: "pr-1",
+ body: "Hydration fix",
+ assignees: [],
+ author: { login: "arul", avatarUrl: null },
+ isDraft: false,
+ labels: [],
+ requestedReviewers: [],
+ milestone: null,
+ linkedIssues: [],
+ }),
+ JSON.stringify({
+ prId: "pr-1",
+ state: "open",
+ checksStatus: "pending",
+ reviewStatus: "requested",
+ isMergeable: true,
+ mergeConflicts: false,
+ behindBaseBy: 0,
+ }),
+ "[]",
+ "[]",
+ "[]",
+ "[]",
+ now,
+ ],
+ );
+}
+
+const activeDisposers: Array<() => Promise> = [];
+
+afterEach(async () => {
+ while (activeDisposers.length > 0) {
+ const dispose = activeDisposers.pop();
+ if (dispose) await dispose();
+ }
+});
+
+describe.skipIf(!isCrsqliteAvailable())("openKvDb CRR repair", () => {
+ it("backfills phone-critical tables whose rows predate CRR enablement", async () => {
+ const projectRoot = makeProjectRoot("ade-kvdb-pre-crr-");
+ const dbPath = path.join(projectRoot, ".ade", "ade.db");
+ const first = await openKvDb(dbPath, createLogger() as any);
+ insertProjectGraph(first);
+ insertSessionAndPr(first);
+
+ first.run("drop table terminal_sessions__crsql_clock");
+ first.run("drop table terminal_sessions__crsql_pks");
+ first.run("drop table pull_request_snapshots__crsql_clock");
+ first.run("drop table pull_request_snapshots__crsql_pks");
+ first.close();
+
+ const reopened = await openKvDb(dbPath, createLogger() as any);
+ activeDisposers.push(async () => reopened.close());
+
+ expect(reopened.get<{ count: number }>("select count(1) as count from terminal_sessions__crsql_pks")?.count).toBe(1);
+ expect(reopened.get<{ count: number }>("select count(1) as count from pull_request_snapshots__crsql_pks")?.count).toBe(1);
+ expect(reopened.get<{ count: number }>("select count(1) as count from terminal_sessions")?.count).toBe(1);
+ expect(reopened.get<{ count: number }>("select count(1) as count from pull_request_snapshots")?.count).toBe(1);
+ });
+
+ it("repairs divergent __crsql_pks counts without losing rows or indexes", async () => {
+ const projectRoot = makeProjectRoot("ade-kvdb-mismatch-");
+ const dbPath = path.join(projectRoot, ".ade", "ade.db");
+ const first = await openKvDb(dbPath, createLogger() as any);
+ insertProjectGraph(first);
+ insertSessionAndPr(first);
+
+ first.run("delete from lanes__crsql_pks where __crsql_key = (select max(__crsql_key) from lanes__crsql_pks)");
+ first.run(
+ "delete from lane_state_snapshots__crsql_pks where __crsql_key = (select max(__crsql_key) from lane_state_snapshots__crsql_pks)",
+ );
+ first.run("delete from terminal_sessions__crsql_pks");
+ first.run("delete from pull_requests__crsql_pks");
+ first.close();
+
+ const reopened = await openKvDb(dbPath, createLogger() as any);
+ activeDisposers.push(async () => reopened.close());
+
+ expect(reopened.get<{ count: number }>("select count(1) as count from lanes")?.count).toBe(2);
+ expect(reopened.get<{ count: number }>("select count(1) as count from lanes__crsql_pks")?.count).toBe(2);
+ expect(reopened.get<{ count: number }>("select count(1) as count from lane_state_snapshots__crsql_pks")?.count).toBe(2);
+ expect(reopened.get<{ count: number }>("select count(1) as count from terminal_sessions__crsql_pks")?.count).toBe(1);
+ expect(reopened.get<{ count: number }>("select count(1) as count from pull_requests__crsql_pks")?.count).toBe(1);
+ expect(
+ reopened.get<{ present: number }>(
+ "select 1 as present from sqlite_master where type = 'index' and name = 'idx_terminal_sessions_started_at' limit 1",
+ )?.present,
+ ).toBe(1);
+ });
+});
diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts
index 74035377..3b5834ad 100644
--- a/apps/desktop/src/main/services/state/kvDb.ts
+++ b/apps/desktop/src/main/services/state/kvDb.ts
@@ -244,12 +244,136 @@ function hasCrsqlMetadata(db: DatabaseSyncType): boolean {
);
}
-function ensureCrrTables(db: DatabaseSyncType): void {
+const PHONE_CRITICAL_CRR_TABLES = [
+ "lanes",
+ "lane_state_snapshots",
+ "terminal_sessions",
+ "pull_requests",
+ "pull_request_snapshots",
+] as const;
+
+function countTableRows(db: DatabaseSyncType, tableName: string): number {
+ const row = getRow<{ count: number }>(db, `select count(1) as count from ${quoteIdentifier(tableName)}`);
+ return Number(row?.count ?? 0);
+}
+
+function tableNeedsCrrRepair(db: DatabaseSyncType, tableName: string): { baseRowCount: number; pkRowCount: number } | null {
+ const baseRowCount = countTableRows(db, tableName);
+ if (baseRowCount <= 0) {
+ return null;
+ }
+
+ const pksTable = `${tableName}__crsql_pks`;
+ if (!rawHasTable(db, pksTable)) {
+ return { baseRowCount, pkRowCount: 0 };
+ }
+
+ const pkRowCount = countTableRows(db, pksTable);
+ return pkRowCount === baseRowCount ? null : { baseRowCount, pkRowCount };
+}
+
+function rebuildCrrTableWithBackfill(db: DatabaseSyncType, tableName: string): void {
+ const tableRow = getRow<{ sql: string | null }>(
+ db,
+ "select sql from sqlite_master where type = 'table' and name = ? limit 1",
+ [tableName],
+ );
+ const createSql = tableRow?.sql?.trim();
+ if (!createSql) {
+ throw new Error(`Unable to repair CRR table ${tableName}: create SQL missing.`);
+ }
+
+ const columns = allRows<{ name: string }>(db, `pragma table_info('${tableName.replace(/'/g, "''")}')`);
+ if (columns.length === 0) {
+ throw new Error(`Unable to repair CRR table ${tableName}: no columns found.`);
+ }
+
+ const stageTable = `__ade_crr_stage_${tableName}`;
+ const columnsSql = columns.map((column) => quoteIdentifier(column.name)).join(", ");
+ const indexSqls = allRows<{ sql: string | null }>(
+ db,
+ "select sql from sqlite_master where type = 'index' and tbl_name = ? and sql is not null order by name asc",
+ [tableName],
+ )
+ .map((row) => row.sql?.trim() ?? "")
+ .filter((sql) => sql.length > 0);
+
+ runStatement(db, "pragma foreign_keys = off");
+ runStatement(db, "begin");
+ try {
+ runStatement(
+ db,
+ `create temp table ${quoteIdentifier(stageTable)} as select ${columnsSql} from ${quoteIdentifier(tableName)}`,
+ );
+ runStatement(db, `drop table ${quoteIdentifier(tableName)}`);
+ if (rawHasTable(db, `${tableName}__crsql_clock`)) {
+ runStatement(db, `drop table ${quoteIdentifier(`${tableName}__crsql_clock`)}`);
+ }
+ if (rawHasTable(db, `${tableName}__crsql_pks`)) {
+ runStatement(db, `drop table ${quoteIdentifier(`${tableName}__crsql_pks`)}`);
+ }
+ runStatement(db, createSql);
+ for (const indexSql of indexSqls) {
+ runStatement(db, indexSql);
+ }
+ getRow(db, "select crsql_as_crr(?) as ok", [tableName]);
+ runStatement(
+ db,
+ `insert into ${quoteIdentifier(tableName)} (${columnsSql}) select ${columnsSql} from ${quoteIdentifier(stageTable)}`,
+ );
+ runStatement(db, `drop table ${quoteIdentifier(stageTable)}`);
+ runStatement(db, "commit");
+ } catch (error) {
+ runStatement(db, "rollback");
+ throw error;
+ } finally {
+ runStatement(db, "pragma foreign_keys = on");
+ }
+}
+
+function ensureCrrTables(db: DatabaseSyncType, logger?: Logger): void {
+ const repairTargets = new Set(PHONE_CRITICAL_CRR_TABLES);
for (const tableName of listEligibleCrrTables(db)) {
if (rawHasTable(db, `${tableName}__crsql_clock`)) {
+ if (!repairTargets.has(tableName)) {
+ continue;
+ }
+ } else {
+ getRow(db, "select crsql_as_crr(?) as ok", [tableName]);
+ }
+
+ if (!repairTargets.has(tableName)) {
continue;
}
- getRow(db, "select crsql_as_crr(?) as ok", [tableName]);
+
+ const mismatch = tableNeedsCrrRepair(db, tableName);
+ if (!mismatch) {
+ continue;
+ }
+
+ logger?.warn("db.crr_integrity_mismatch", {
+ tableName,
+ baseRowCount: mismatch.baseRowCount,
+ pkRowCount: mismatch.pkRowCount,
+ });
+ try {
+ rebuildCrrTableWithBackfill(db, tableName);
+ const remainingMismatch = tableNeedsCrrRepair(db, tableName);
+ if (remainingMismatch) {
+ logger?.warn("db.crr_integrity_repair_incomplete", {
+ tableName,
+ baseRowCount: remainingMismatch.baseRowCount,
+ pkRowCount: remainingMismatch.pkRowCount,
+ });
+ } else {
+ logger?.info("db.crr_integrity_repaired", { tableName, rowCount: mismatch.baseRowCount });
+ }
+ } catch (error) {
+ logger?.warn("db.crr_integrity_repair_failed", {
+ tableName,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
}
}
@@ -2654,17 +2778,22 @@ function migrate(db: { run: (sql: string, params?: SqlValue[]) => void }) {
}
+function loadCrsqlite(db: DatabaseSyncType, extensionPath: string): void {
+ db.enableLoadExtension(true);
+ db.loadExtension(extensionPath);
+}
+
export async function openKvDb(dbPath: string, logger: Logger): Promise {
const extensionPath = resolveCrsqliteExtensionPath();
+ const hasCrsqlite = extensionPath != null;
const desiredSiteId = ensureLocalSiteIdFile(dbPath);
const existedBeforeOpen = fs.existsSync(dbPath);
let db = openRawDatabase(dbPath);
try {
const hadCrsqlMetadata = hasCrsqlMetadata(db);
- if (hadCrsqlMetadata) {
- db.enableLoadExtension(true);
- db.loadExtension(extensionPath);
+ if (hadCrsqlMetadata && hasCrsqlite) {
+ loadCrsqlite(db, extensionPath);
}
migrate({
@@ -2680,9 +2809,8 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise {
if (retrofitLegacyPrimaryKeyNotNullSchema(db)) {
db.close();
db = openRawDatabase(dbPath);
- if (hadCrsqlMetadata) {
- db.enableLoadExtension(true);
- db.loadExtension(extensionPath);
+ if (hadCrsqlMetadata && hasCrsqlite) {
+ loadCrsqlite(db, extensionPath);
}
migrate({
run: (sql: string, params: SqlValue[] = []) => {
@@ -2691,19 +2819,21 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise {
});
}
- if (!hadCrsqlMetadata) {
- db.enableLoadExtension(true);
- db.loadExtension(extensionPath);
- }
- ensureCrrTables(db);
- forceSiteId(db, desiredSiteId);
-
- if (readCurrentSiteId(db) !== desiredSiteId) {
- db.close();
- db = openRawDatabase(dbPath);
- db.enableLoadExtension(true);
- db.loadExtension(extensionPath);
+ if (hasCrsqlite) {
+ if (!hadCrsqlMetadata) {
+ loadCrsqlite(db, extensionPath);
+ }
+ ensureCrrTables(db, logger);
forceSiteId(db, desiredSiteId);
+
+ if (readCurrentSiteId(db) !== desiredSiteId) {
+ db.close();
+ db = openRawDatabase(dbPath);
+ loadCrsqlite(db, extensionPath);
+ forceSiteId(db, desiredSiteId);
+ }
+ } else {
+ logger.warn("db.crsqlite_unavailable", { dbPath, reason: "extension not found for this platform" });
}
} catch (err) {
try {
@@ -2726,7 +2856,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise {
const run = (sql: string, params: SqlValue[] = []) => {
const alterTable = parseAlterTableTarget(sql);
- if (alterTable && rawHasTable(db, `${alterTable}__crsql_clock`)) {
+ if (hasCrsqlite && alterTable && rawHasTable(db, `${alterTable}__crsql_clock`)) {
getRow(db, "select crsql_begin_alter(?) as ok", [alterTable]);
try {
runStatement(db, sql, params);
@@ -2747,13 +2877,17 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise {
return getRow(db, sql, params);
};
+ const crsqliteUnavailableError = () => new Error("cr-sqlite extension not available on this platform");
+
const sync: AdeDbSyncApi = {
getSiteId: () => desiredSiteId,
getDbVersion: () => {
+ if (!hasCrsqlite) throw crsqliteUnavailableError();
const row = get<{ db_version: number }>("select crsql_db_version() as db_version");
return Number(row?.db_version ?? 0);
},
exportChangesSince: (version: number) => {
+ if (!hasCrsqlite) throw crsqliteUnavailableError();
const rows = allRows<{
table_name: string;
pk: unknown;
@@ -2794,6 +2928,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise {
}));
},
applyChanges: (changes: CrsqlChangeRow[]) => {
+ if (!hasCrsqlite) throw crsqliteUnavailableError();
let appliedCount = 0;
const touchedTables = new Set();
runStatement(db, "begin");
diff --git a/apps/desktop/src/main/services/state/onConflictAudit.test.ts b/apps/desktop/src/main/services/state/onConflictAudit.test.ts
index 791c3dab..c8441a52 100644
--- a/apps/desktop/src/main/services/state/onConflictAudit.test.ts
+++ b/apps/desktop/src/main/services/state/onConflictAudit.test.ts
@@ -55,6 +55,11 @@ const APPROVED_CONFLICT_TARGETS: ConflictTarget[] = [
table: "worker_agents",
columns: "id",
},
+ {
+ file: "src/main/services/lanes/laneService.ts",
+ table: "lane_state_snapshots",
+ columns: "lane_id",
+ },
{
file: "src/main/services/memory/proceduralLearningService.ts",
table: "memory_procedure_details",
@@ -80,21 +85,16 @@ const APPROVED_CONFLICT_TARGETS: ConflictTarget[] = [
table: "orchestrator_run_state",
columns: "run_id",
},
- {
- file: "src/main/services/packs/packService.ts",
- table: "pack_heads",
- columns: "project_id,pack_key",
- },
- {
- file: "src/main/services/packs/packUtils.ts",
- table: "packs_index",
- columns: "pack_key",
- },
{
file: "src/main/services/processes/processService.ts",
table: "process_runtime",
columns: "project_id,lane_id,process_key",
},
+ {
+ file: "src/main/services/prs/prService.ts",
+ table: "pull_request_snapshots",
+ columns: "pr_id",
+ },
{
file: "src/main/services/prs/queueLandingService.ts",
table: "queue_landing_state",
diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.ts
index bf28f71a..22a1b80b 100644
--- a/apps/desktop/src/main/services/sync/deviceRegistryService.ts
+++ b/apps/desktop/src/main/services/sync/deviceRegistryService.ts
@@ -78,7 +78,7 @@ function mapDeviceRow(row: DeviceRow | null): SyncDeviceRecord | null {
updatedAt: String(row.updated_at),
lastSeenAt: row.last_seen_at ? String(row.last_seen_at) : null,
lastHost: row.last_host ? String(row.last_host) : null,
- lastPort: typeof row.last_port === "number" ? row.last_port : row.last_port == null ? null : Number(row.last_port),
+ lastPort: row.last_port == null ? null : Number(row.last_port),
tailscaleIp: row.tailscale_ip ? String(row.tailscale_ip) : null,
ipAddresses: readJsonArray(row.ip_addresses_json),
metadata: safeJsonParse>(row.metadata_json, {}),
@@ -96,16 +96,38 @@ function mapClusterStateRow(row: ClusterStateRow | null): SyncClusterState | nul
};
}
-function listLocalIpAddresses(): string[] {
+type LocalNetworkMetadata = {
+ lanIpAddresses: string[];
+ tailscaleIp: string | null;
+};
+
+function isTailscaleAddress(ipAddress: string): boolean {
+ const parts = ipAddress.split(".");
+ if (parts.length !== 4) return false;
+ const octets = parts.map((part) => Number(part));
+ if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false;
+ return octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127;
+}
+
+function readLocalNetworkMetadata(): LocalNetworkMetadata {
const interfaces = os.networkInterfaces();
- const values: string[] = [];
- for (const entries of Object.values(interfaces)) {
+ const lan: string[] = [];
+ const tailscale: string[] = [];
+ for (const [interfaceName, entries] of Object.entries(interfaces)) {
+ const isLikelyTailscaleInterface = /tailscale|utun|tun/i.test(interfaceName);
for (const entry of entries ?? []) {
if (!entry || entry.internal || entry.family !== "IPv4") continue;
- values.push(entry.address);
+ if (isLikelyTailscaleInterface || isTailscaleAddress(entry.address)) {
+ tailscale.push(entry.address);
+ } else {
+ lan.push(entry.address);
+ }
}
}
- return uniqueStrings(values);
+ return {
+ lanIpAddresses: uniqueStrings(lan),
+ tailscaleIp: uniqueStrings(tailscale)[0] ?? null,
+ };
}
function firstPreferredHost(ipAddresses: string[]): string {
@@ -129,13 +151,14 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) {
const localSiteId = args.db.sync.getSiteId();
const getLocalDefaults = () => {
- const ipAddresses = listLocalIpAddresses();
+ const network = readLocalNetworkMetadata();
return {
name: os.hostname(),
platform: mapPlatform(process.platform),
deviceType: "desktop" as SyncPeerDeviceType,
- ipAddresses,
- lastHost: firstPreferredHost(ipAddresses),
+ ipAddresses: network.lanIpAddresses,
+ tailscaleIp: network.tailscaleIp,
+ lastHost: firstPreferredHost(network.lanIpAddresses),
};
};
@@ -203,17 +226,21 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) {
};
const ensureLocalDevice = (): SyncDeviceRecord => {
+ const existing = mapDeviceRow(args.db.get("select * from devices where device_id = ? limit 1", [localDeviceId]));
const defaults = getLocalDefaults();
return upsertDeviceRecord({
deviceId: localDeviceId,
siteId: localSiteId,
- name: defaults.name,
- platform: defaults.platform,
- deviceType: defaults.deviceType,
+ name: existing?.name ?? defaults.name,
+ platform: existing?.platform ?? defaults.platform,
+ deviceType: existing?.deviceType ?? defaults.deviceType,
lastSeenAt: nowIso(),
- lastHost: defaults.lastHost,
+ lastHost: existing?.lastHost ?? defaults.lastHost,
+ lastPort: existing?.lastPort ?? null,
+ tailscaleIp: defaults.tailscaleIp,
ipAddresses: defaults.ipAddresses,
metadata: {
+ ...(existing?.metadata ?? {}),
hostname: os.hostname(),
},
});
@@ -303,6 +330,7 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) {
metadata?: Record;
} = {}): SyncDeviceRecord => {
const current = ensureLocalDevice();
+ const network = readLocalNetworkMetadata();
return upsertDeviceRecord({
deviceId: current.deviceId,
siteId: current.siteId,
@@ -310,10 +338,10 @@ export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) {
platform: current.platform,
deviceType: current.deviceType,
lastSeenAt: argsIn.lastSeenAt ?? nowIso(),
- lastHost: argsIn.lastHost ?? current.lastHost,
+ lastHost: argsIn.lastHost ?? current.lastHost ?? firstPreferredHost(network.lanIpAddresses),
lastPort: argsIn.lastPort ?? current.lastPort,
- tailscaleIp: current.tailscaleIp,
- ipAddresses: current.ipAddresses,
+ tailscaleIp: network.tailscaleIp ?? current.tailscaleIp,
+ ipAddresses: network.lanIpAddresses.length > 0 ? network.lanIpAddresses : current.ipAddresses,
metadata: {
...current.metadata,
...(argsIn.metadata ?? {}),
diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts
index fff8acea..3a8af593 100644
--- a/apps/desktop/src/main/services/sync/syncHostService.test.ts
+++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts
@@ -4,6 +4,7 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import { openKvDb } from "../state/kvDb";
+import { isCrsqliteAvailable } from "../state/crsqliteExtension";
import { createSyncHostService } from "./syncHostService";
import { encodeSyncEnvelope, parseSyncEnvelope } from "./syncProtocol";
import type { ParsedSyncEnvelope } from "./syncProtocol";
@@ -197,7 +198,7 @@ afterEach(async () => {
}
});
-describe("syncHostService", () => {
+describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => {
it("authenticates peers, relays CRDT changes, and rebroadcasts to other peers", async () => {
const brainDb = await openKvDb(makeDbPath("ade-sync-brain-"), createLogger() as any);
const dbA = await openKvDb(makeDbPath("ade-sync-peer-a-"), createLogger() as any);
@@ -219,6 +220,59 @@ describe("syncHostService", () => {
} as any,
prService: {
listAll: vi.fn().mockResolvedValue([]),
+ refresh: vi.fn().mockResolvedValue([
+ {
+ id: "pr-1",
+ laneId: "lane-1",
+ projectId: "project-1",
+ repoOwner: "arul",
+ repoName: "ade",
+ githubPrNumber: 42,
+ githubUrl: "https://github.com/arul/ade/pull/42",
+ githubNodeId: "node-42",
+ title: "Fix mobile hydration",
+ state: "open",
+ baseBranch: "main",
+ headBranch: "ade/mobile-hydration",
+ checksStatus: "pending",
+ reviewStatus: "requested",
+ additions: 12,
+ deletions: 4,
+ lastSyncedAt: "2026-03-17T00:10:00.000Z",
+ createdAt: "2026-03-17T00:10:00.000Z",
+ updatedAt: "2026-03-17T00:10:00.000Z",
+ },
+ ]),
+ listSnapshots: vi.fn().mockReturnValue([
+ {
+ prId: "pr-1",
+ detail: {
+ prId: "pr-1",
+ body: "Hydration fix",
+ assignees: [],
+ author: { login: "arul", avatarUrl: null },
+ isDraft: false,
+ labels: [],
+ requestedReviewers: [],
+ milestone: null,
+ linkedIssues: [],
+ },
+ status: {
+ prId: "pr-1",
+ state: "open",
+ checksStatus: "pending",
+ reviewStatus: "requested",
+ isMergeable: true,
+ mergeConflicts: false,
+ behindBaseBy: 0,
+ },
+ checks: [],
+ reviews: [],
+ comments: [],
+ files: [],
+ updatedAt: "2026-03-17T00:10:00.000Z",
+ },
+ ]),
getDetail: vi.fn(),
getStatus: vi.fn(),
getChecks: vi.fn(),
@@ -294,7 +348,7 @@ describe("syncHostService", () => {
expect(payload.changes.length).toBeGreaterThan(0);
dbB.sync.applyChanges(payload.changes as any);
expect(dbB.getJson<{ value: string }>("replicated-state")).toEqual({ value: "hello" });
- });
+ }, 60_000);
it("serves workspace file operations and artifact reads while blocking .git access", async () => {
const brainDb = await openKvDb(makeDbPath("ade-sync-files-"), createLogger() as any);
@@ -319,7 +373,64 @@ describe("syncHostService", () => {
archive: vi.fn(),
} as any,
prService: {
- listAll: vi.fn().mockResolvedValue([]),
+ listAll: vi.fn().mockResolvedValue([
+ {
+ id: "pr-1",
+ laneId: "lane-1",
+ projectId: "project-1",
+ repoOwner: "arul",
+ repoName: "ade",
+ githubPrNumber: 42,
+ githubUrl: "https://github.com/arul/ade/pull/42",
+ githubNodeId: "node-42",
+ title: "Fix mobile hydration",
+ state: "open",
+ baseBranch: "main",
+ headBranch: "ade/mobile-hydration",
+ checksStatus: "pending",
+ reviewStatus: "requested",
+ additions: 12,
+ deletions: 4,
+ lastSyncedAt: "2026-03-17T00:10:00.000Z",
+ createdAt: "2026-03-17T00:10:00.000Z",
+ updatedAt: "2026-03-17T00:10:00.000Z",
+ },
+ ]),
+ refresh: vi.fn().mockResolvedValue([
+ {
+ id: "pr-1",
+ },
+ ]),
+ listSnapshots: vi.fn().mockReturnValue([
+ {
+ prId: "pr-1",
+ detail: {
+ prId: "pr-1",
+ body: "Hydration fix",
+ assignees: [],
+ author: { login: "arul", avatarUrl: null },
+ isDraft: false,
+ labels: [],
+ requestedReviewers: [],
+ milestone: null,
+ linkedIssues: [],
+ },
+ status: {
+ prId: "pr-1",
+ state: "open",
+ checksStatus: "pending",
+ reviewStatus: "requested",
+ isMergeable: true,
+ mergeConflicts: false,
+ behindBaseBy: 0,
+ },
+ checks: [],
+ reviews: [],
+ comments: [],
+ files: [],
+ updatedAt: "2026-03-17T00:10:00.000Z",
+ },
+ ]),
getDetail: vi.fn(),
getStatus: vi.fn(),
getChecks: vi.fn(),
@@ -428,7 +539,64 @@ describe("syncHostService", () => {
archive: vi.fn(),
} as any,
prService: {
- listAll: vi.fn().mockResolvedValue([]),
+ listAll: vi.fn().mockResolvedValue([
+ {
+ id: "pr-1",
+ laneId: "lane-1",
+ projectId: "project-1",
+ repoOwner: "arul",
+ repoName: "ade",
+ githubPrNumber: 42,
+ githubUrl: "https://github.com/arul/ade/pull/42",
+ githubNodeId: "node-42",
+ title: "Fix mobile hydration",
+ state: "open",
+ baseBranch: "main",
+ headBranch: "ade/mobile-hydration",
+ checksStatus: "pending",
+ reviewStatus: "requested",
+ additions: 12,
+ deletions: 4,
+ lastSyncedAt: "2026-03-17T00:10:00.000Z",
+ createdAt: "2026-03-17T00:10:00.000Z",
+ updatedAt: "2026-03-17T00:10:00.000Z",
+ },
+ ]),
+ refresh: vi.fn().mockResolvedValue([
+ {
+ id: "pr-1",
+ },
+ ]),
+ listSnapshots: vi.fn().mockReturnValue([
+ {
+ prId: "pr-1",
+ detail: {
+ prId: "pr-1",
+ body: "Hydration fix",
+ assignees: [],
+ author: { login: "arul", avatarUrl: null },
+ isDraft: false,
+ labels: [],
+ requestedReviewers: [],
+ milestone: null,
+ linkedIssues: [],
+ },
+ status: {
+ prId: "pr-1",
+ state: "open",
+ checksStatus: "pending",
+ reviewStatus: "requested",
+ isMergeable: true,
+ mergeConflicts: false,
+ behindBaseBy: 0,
+ },
+ checks: [],
+ reviews: [],
+ comments: [],
+ files: [],
+ updatedAt: "2026-03-17T00:10:00.000Z",
+ },
+ ]),
getDetail: vi.fn(),
getStatus: vi.fn(),
getChecks: vi.fn(),
@@ -441,7 +609,30 @@ describe("syncHostService", () => {
requestReviewers: vi.fn(),
} as any,
sessionService: {
- list: () => [],
+ list: () => [
+ {
+ id: "session-1",
+ laneId: "lane-1",
+ laneName: "Primary",
+ ptyId: "pty-1",
+ tracked: true,
+ pinned: false,
+ goal: "Run tests",
+ toolType: "run-shell",
+ title: "npm test",
+ status: "running",
+ startedAt: "2026-03-17T00:10:00.000Z",
+ endedAt: null,
+ exitCode: null,
+ transcriptPath: path.join(projectRoot, ".ade", "transcripts", "session-1.log"),
+ headShaStart: null,
+ headShaEnd: null,
+ lastOutputPreview: "prior output",
+ summary: null,
+ runtimeState: "running",
+ resumeCommand: "npm test",
+ },
+ ],
get: () => ({
id: "session-1",
transcriptPath: path.join(projectRoot, ".ade", "transcripts", "session-1.log"),
@@ -520,6 +711,45 @@ describe("syncHostService", () => {
expect((result.payload as { ok: boolean; result: { sessionId: string } }).result.sessionId).toBe("session-1");
expect(createSpy).toHaveBeenCalledTimes(1);
+ client.ws.send(encodeSyncEnvelope({
+ type: "command",
+ requestId: "cmd-work-list",
+ payload: {
+ commandId: "cmd-work-list",
+ action: "work.listSessions",
+ args: {},
+ },
+ }));
+ const workListAck = await client.queue.next("command_ack");
+ expect((workListAck.payload as { accepted: boolean }).accepted).toBe(true);
+ const workListResult = await client.queue.next("command_result");
+ const workSessions = (workListResult.payload as { ok: boolean; result: Array<{ id: string }> }).result;
+ expect(workSessions.map((entry) => entry.id)).toEqual(["session-1"]);
+
+ client.ws.send(encodeSyncEnvelope({
+ type: "command",
+ requestId: "cmd-pr-refresh",
+ payload: {
+ commandId: "cmd-pr-refresh",
+ action: "prs.refresh",
+ args: {},
+ },
+ }));
+ const prRefreshAck = await client.queue.next("command_ack");
+ expect((prRefreshAck.payload as { accepted: boolean }).accepted).toBe(true);
+ const prRefreshResult = await client.queue.next("command_result");
+ const prRefreshPayload = prRefreshResult.payload as {
+ ok: boolean;
+ result: {
+ refreshedCount: number;
+ prs: Array<{ id: string }>;
+ snapshots: Array<{ prId: string }>;
+ };
+ };
+ expect(prRefreshPayload.result.refreshedCount).toBe(1);
+ expect(prRefreshPayload.result.prs.map((entry) => entry.id)).toEqual(["pr-1"]);
+ expect(prRefreshPayload.result.snapshots.map((entry) => entry.prId)).toEqual(["pr-1"]);
+
client.ws.send(encodeSyncEnvelope({
type: "command",
requestId: "cmd-unsupported",
@@ -640,9 +870,67 @@ describe("syncHostService", () => {
},
}));
const helloOk = await authQueue.next("hello_ok");
- const helloPayload = helloOk.payload as { features: { pairingAuth: { enabled: boolean } } };
+ const helloPayload = helloOk.payload as {
+ features: {
+ pairingAuth: { enabled: boolean };
+ commandRouting: {
+ supportedActions: string[];
+ actions: Array<{ action: string; policy: { queueable?: boolean; viewerAllowed: boolean } }>;
+ };
+ };
+ };
expect(helloPayload.features.pairingAuth.enabled).toBe(true);
- authWs.close();
- await new Promise((resolve) => authWs.once("close", resolve));
+ expect(helloPayload.features.commandRouting.supportedActions).toContain("lanes.getDetail");
+ expect(helloPayload.features.commandRouting.supportedActions).toContain("lanes.rename");
+ const getDetailDescriptor = helloPayload.features.commandRouting.actions.find(
+ (entry) => entry.action === "lanes.getDetail",
+ );
+ expect(getDetailDescriptor?.policy.viewerAllowed).toBe(true);
+ expect(getDetailDescriptor?.policy.queueable).toBeUndefined();
+ expect(helloPayload.features.commandRouting.actions).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ action: "lanes.rename",
+ policy: expect.objectContaining({ viewerAllowed: true, queueable: true }),
+ }),
+ ]),
+ );
+ expect(host.getPeerStates().map((peer) => peer.deviceId)).toContain("ios-phone-1");
+
+ host.revokePairedDevice("ios-phone-1");
+ if (authWs.readyState !== WebSocket.CLOSED) {
+ await new Promise((resolve) => authWs.once("close", resolve));
+ }
+ await waitFor(() => !host.getPeerStates().some((peer) => peer.deviceId === "ios-phone-1"));
+ const revokedWs = new WebSocket(`ws://127.0.0.1:${port}`);
+ await new Promise((resolve, reject) => {
+ revokedWs.once("open", () => resolve());
+ revokedWs.once("error", reject);
+ });
+ const revokedQueue = createMessageQueue(revokedWs);
+ revokedWs.send(encodeSyncEnvelope({
+ type: "hello",
+ requestId: "hello-revoked",
+ payload: {
+ peer: {
+ deviceId: "ios-phone-1",
+ deviceName: "Arul iPhone",
+ platform: "iOS",
+ deviceType: "phone",
+ siteId: "ios-site-1",
+ dbVersion: 0,
+ },
+ auth: {
+ kind: "paired",
+ deviceId: "ios-phone-1",
+ secret: pairingPayload.secret,
+ },
+ },
+ }));
+ const revokedHello = await revokedQueue.next("hello_error");
+ const revokedPayload = revokedHello.payload as { code: string; message: string };
+ expect(revokedPayload.code).toBe("auth_failed");
+ revokedWs.close();
+ await new Promise((resolve) => revokedWs.once("close", resolve));
});
});
diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts
index 14cbefe4..60567fcd 100644
--- a/apps/desktop/src/main/services/sync/syncHostService.ts
+++ b/apps/desktop/src/main/services/sync/syncHostService.ts
@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { randomBytes } from "node:crypto";
+import { Bonjour, type Service as BonjourService } from "bonjour-service";
import { WebSocketServer, WebSocket, type RawData } from "ws";
import { resolveAdeLayout } from "../../../shared/adeLayout";
import type {
@@ -29,8 +30,18 @@ import type {
SyncTerminalSnapshotPayload,
} from "../../../shared/types";
import type { Logger } from "../logging/logger";
+import type { createAgentChatService } from "../chat/agentChatService";
+import type { createProjectConfigService } from "../config/projectConfigService";
+import type { createConflictService } from "../conflicts/conflictService";
import type { createFileService } from "../files/fileService";
+import type { createDiffService } from "../diffs/diffService";
+import type { createGitOperationsService } from "../git/gitOperationsService";
+import type { createAutoRebaseService } from "../lanes/autoRebaseService";
+import type { createLaneEnvironmentService } from "../lanes/laneEnvironmentService";
import type { createLaneService } from "../lanes/laneService";
+import type { createLaneTemplateService } from "../lanes/laneTemplateService";
+import type { createPortAllocationService } from "../lanes/portAllocationService";
+import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService";
import type { createPtyService } from "../pty/ptyService";
import type { createPrService } from "../prs/prService";
import type { createSessionService } from "../sessions/sessionService";
@@ -45,11 +56,14 @@ const DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS = 30_000;
const DEFAULT_SYNC_POLL_INTERVAL_MS = 400;
const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000;
const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000;
+const SYNC_MDNS_SERVICE_TYPE = "ade-sync";
type PeerState = {
ws: WebSocket;
metadata: SyncPeerMetadata | null;
authenticated: boolean;
+ authKind: "bootstrap" | "paired" | null;
+ pairedDeviceId: string | null;
connectedAt: string;
lastSeenAt: string;
lastAppliedAt: string | null;
@@ -67,9 +81,19 @@ type SyncHostServiceArgs = {
projectRoot: string;
fileService: ReturnType;
laneService: ReturnType;
+ gitService?: ReturnType;
+ diffService?: ReturnType;
+ conflictService?: ReturnType;
prService: ReturnType;
sessionService: ReturnType;
ptyService: ReturnType;
+ agentChatService?: ReturnType;
+ projectConfigService?: ReturnType;
+ portAllocationService?: ReturnType;
+ laneEnvironmentService?: ReturnType;
+ laneTemplateService?: ReturnType;
+ rebaseSuggestionService?: ReturnType;
+ autoRebaseService?: ReturnType;
computerUseArtifactBrokerService: ReturnType;
bootstrapTokenPath?: string;
port?: number;
@@ -240,6 +264,17 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
prService: args.prService,
ptyService: args.ptyService,
sessionService: args.sessionService,
+ fileService: args.fileService,
+ gitService: args.gitService,
+ diffService: args.diffService,
+ conflictService: args.conflictService,
+ agentChatService: args.agentChatService,
+ projectConfigService: args.projectConfigService,
+ portAllocationService: args.portAllocationService,
+ laneEnvironmentService: args.laneEnvironmentService,
+ laneTemplateService: args.laneTemplateService,
+ rebaseSuggestionService: args.rebaseSuggestionService,
+ autoRebaseService: args.autoRebaseService,
logger: args.logger,
});
const heartbeatIntervalMs = Math.max(5_000, Math.floor(args.heartbeatIntervalMs ?? DEFAULT_SYNC_HEARTBEAT_INTERVAL_MS));
@@ -267,6 +302,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
});
let disposed = false;
+ let bonjourInstance: Bonjour | null = null;
+ let bonjourAnnouncement: BonjourService | null = null;
+ let bonjourPort: number | null = null;
let lastBroadcastAt: string | null = null;
const startedAtMs = Date.now();
const pollTimer = setInterval(() => {
@@ -299,6 +337,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
ws,
metadata: null,
authenticated: false,
+ authKind: null,
+ pairedDeviceId: null,
connectedAt: nowIso(),
lastSeenAt: nowIso(),
lastAppliedAt: null,
@@ -331,6 +371,53 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
});
});
+ const publishLanDiscovery = (port: number): void => {
+ if (disposed) return;
+ if (bonjourAnnouncement && bonjourPort === port) return;
+ const localDevice = args.deviceRegistryService?.ensureLocalDevice() ?? null;
+ const hostName = localDevice?.name ?? os.hostname();
+ const ipAddresses = (localDevice?.ipAddresses ?? []).filter((value) => value.trim().length > 0);
+ const txt = {
+ version: "1",
+ deviceId: localDevice?.deviceId ?? "",
+ siteId: localDevice?.siteId ?? "",
+ deviceName: hostName,
+ port: String(port),
+ host: localDevice?.lastHost ?? "",
+ addresses: ipAddresses.join(","),
+ tailscaleIp: localDevice?.tailscaleIp ?? "",
+ };
+ if (!bonjourInstance) {
+ bonjourInstance = new Bonjour(undefined, (error: unknown) => {
+ args.logger.warn("sync_host.discovery_error", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ });
+ }
+ if (bonjourAnnouncement) {
+ try {
+ bonjourAnnouncement.stop?.();
+ } catch {
+ // ignore cleanup failures
+ }
+ bonjourAnnouncement = null;
+ }
+ bonjourPort = port;
+ bonjourAnnouncement = bonjourInstance.publish({
+ name: `ADE Sync ${hostName} ${port}`,
+ type: SYNC_MDNS_SERVICE_TYPE,
+ protocol: "tcp",
+ port,
+ txt,
+ disableIPv6: true,
+ });
+ bonjourAnnouncement.on("error", (error: unknown) => {
+ args.logger.warn("sync_host.discovery_publish_failed", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ });
+ };
+
function send(ws: WebSocket, type: SyncEnvelope["type"], payload: TPayload, requestId?: string | null): void {
ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }));
}
@@ -653,6 +740,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
peer.authenticated = true;
peer.metadata = hello.peer;
+ const auth = hello.auth ?? { kind: "bootstrap", token: "" };
+ peer.authKind = auth.kind;
+ peer.pairedDeviceId = auth.kind === "paired" ? auth.deviceId : null;
peer.lastKnownServerDbVersion = Math.max(0, Math.floor(hello.peer.dbVersion));
args.deviceRegistryService?.upsertPeerMetadata(hello.peer, {
lastSeenAt: nowIso(),
@@ -676,6 +766,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
commandRouting: {
mode: "allowlisted",
supportedActions: remoteCommandService.getSupportedActions(),
+ actions: remoteCommandService.getDescriptors(),
},
},
}, envelope.requestId);
@@ -761,13 +852,17 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
async waitUntilListening(): Promise {
if (server.address()) {
const address = server.address();
- return typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT;
+ const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT;
+ publishLanDiscovery(port);
+ return port;
}
await new Promise((resolve) => {
server.once("listening", () => resolve());
});
const address = server.address();
- return typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT;
+ const port = typeof address === "object" && address ? address.port : DEFAULT_SYNC_HOST_PORT;
+ publishLanDiscovery(port);
+ return port;
},
getPort(): number | null {
@@ -785,6 +880,24 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
revokePairedDevice(deviceId: string): void {
pairingStore.revoke(deviceId);
+ let revokedConnectedPeer = false;
+ for (const peer of peers) {
+ if (!peer.authenticated || peer.authKind !== "paired" || peer.pairedDeviceId !== deviceId) continue;
+ revokedConnectedPeer = true;
+ peer.authenticated = false;
+ peer.metadata = null;
+ peer.authKind = null;
+ peer.pairedDeviceId = null;
+ try {
+ peer.ws.close(4003, "Pairing revoked");
+ } catch {
+ // ignore close failures
+ }
+ }
+ if (revokedConnectedPeer) {
+ args.onStateChanged?.();
+ broadcastBrainStatus();
+ }
},
getPeerStates(): SyncPeerConnectionState[] {
@@ -840,6 +953,22 @@ export function createSyncHostService(args: SyncHostServiceArgs) {
}
server.close(() => resolve());
});
+ if (bonjourAnnouncement) {
+ try {
+ bonjourAnnouncement.stop?.();
+ } catch {
+ // ignore cleanup failures
+ }
+ bonjourAnnouncement = null;
+ }
+ if (bonjourInstance) {
+ try {
+ bonjourInstance.destroy();
+ } catch {
+ // ignore cleanup failures
+ }
+ bonjourInstance = null;
+ }
},
};
}
diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts
index 19d60c70..736a888f 100644
--- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts
+++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts
@@ -1,19 +1,69 @@
import type {
+ AgentChatCreateArgs,
+ AgentChatGetSummaryArgs,
+ AgentChatListArgs,
+ AgentChatProvider,
+ AgentChatSendArgs,
+ ApplyLaneTemplateArgs,
ArchiveLaneArgs,
+ AttachLaneArgs,
ClosePrArgs,
+ CreateChildLaneArgs,
CreateLaneArgs,
CreatePrFromLaneArgs,
+ DeleteLaneArgs,
+ GetDiffChangesArgs,
+ GetFileDiffArgs,
+ GitBatchFileActionArgs,
+ GitCherryPickArgs,
+ GitCommitArgs,
+ GitFileActionArgs,
+ GitGenerateCommitMessageArgs,
+ GitGetCommitMessageArgs,
+ GitListBranchesArgs,
+ GitListCommitFilesArgs,
+ GitPushArgs,
+ GitRevertArgs,
+ GitStashPushArgs,
+ GitStashRefArgs,
+ GitSyncArgs,
LandPrArgs,
+ LaneEnvInitConfig,
+ LaneEnvInitProgress,
+ LaneDetailPayload,
+ LaneListSnapshot,
+ LaneOverlayOverrides,
+ LaneStateSnapshotSummary,
ListLanesArgs,
ListSessionsArgs,
+ RebasePushArgs,
+ RebaseStartArgs,
+ RenameLaneArgs,
+ ReopenPrArgs,
+ ReparentLaneArgs,
RequestPrReviewersArgs,
SyncCommandPayload,
SyncRemoteCommandAction,
+ SyncRemoteCommandDescriptor,
SyncRemoteCommandPolicy,
SyncRunQuickCommandArgs,
TerminalToolType,
+ UpdateLaneAppearanceArgs,
+ WriteTextAtomicArgs,
} from "../../../shared/types";
+import type { createAgentChatService } from "../chat/agentChatService";
+import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher";
+import type { createProjectConfigService } from "../config/projectConfigService";
+import type { createConflictService } from "../conflicts/conflictService";
+import type { createDiffService } from "../diffs/diffService";
+import type { createFileService } from "../files/fileService";
+import type { createGitOperationsService } from "../git/gitOperationsService";
+import type { createAutoRebaseService } from "../lanes/autoRebaseService";
+import type { createLaneEnvironmentService } from "../lanes/laneEnvironmentService";
import type { createLaneService } from "../lanes/laneService";
+import type { createLaneTemplateService } from "../lanes/laneTemplateService";
+import type { createPortAllocationService } from "../lanes/portAllocationService";
+import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService";
import type { Logger } from "../logging/logger";
import type { createPrService } from "../prs/prService";
import type { createPtyService } from "../pty/ptyService";
@@ -24,11 +74,22 @@ type SyncRemoteCommandServiceArgs = {
prService: ReturnType;
ptyService: ReturnType;
sessionService: ReturnType;
+ fileService: ReturnType;
+ gitService?: ReturnType;
+ diffService?: ReturnType;
+ conflictService?: ReturnType;
+ agentChatService?: ReturnType;
+ projectConfigService?: ReturnType;
+ portAllocationService?: ReturnType | null;
+ laneEnvironmentService?: ReturnType | null;
+ laneTemplateService?: ReturnType | null;
+ rebaseSuggestionService?: ReturnType | null;
+ autoRebaseService?: ReturnType | null;
logger: Logger;
};
type RegisteredRemoteCommand = {
- policy: SyncRemoteCommandPolicy;
+ descriptor: SyncRemoteCommandDescriptor;
handler: (args: Record) => Promise;
};
@@ -53,6 +114,23 @@ function asStringArray(value: unknown): string[] {
return value.map((entry) => asTrimmedString(entry)).filter((entry): entry is string => Boolean(entry));
}
+function requireString(value: unknown, message: string): string {
+ const parsed = asTrimmedString(value);
+ if (!parsed) throw new Error(message);
+ return parsed;
+}
+
+function requireStringArray(value: unknown, message: string): string[] {
+ const parsed = asStringArray(value);
+ if (parsed.length === 0) throw new Error(message);
+ return parsed;
+}
+
+function requireService(value: T | null | undefined, message: string): T {
+ if (value == null) throw new Error(message);
+ return value;
+}
+
function parseListLanesArgs(value: Record): ListLanesArgs {
return {
includeArchived: asOptionalBoolean(value.includeArchived),
@@ -61,21 +139,98 @@ function parseListLanesArgs(value: Record