Merge pull request #1832 from nexu-io/sync/main-into-preview-v0.8.0

Release preview/v0.8.0 into main
This commit is contained in:
lefarcen 2026-05-15 20:44:27 +08:00 committed by GitHub
commit e40399d39a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1659 changed files with 283777 additions and 2855 deletions

View file

@ -318,6 +318,15 @@ if (env.RELEASE_CHANNEL === "beta") {
betaVersion: env.RELEASE_VERSION,
...commonMetadata,
};
} else if (env.RELEASE_CHANNEL === "preview") {
metadata = {
assetVersionSuffix: env.ASSET_VERSION_SUFFIX ?? "",
baseVersion: env.BASE_VERSION,
previewNumber: Number(env.RELEASE_VERSION.split("-preview.")[1]),
previewVersion: env.RELEASE_VERSION,
releaseVersion: env.RELEASE_VERSION,
...commonMetadata,
};
} else {
metadata = {
baseVersion: env.BASE_VERSION,

View file

@ -61,6 +61,17 @@ if (metadata.channel === "beta") {
if (metadata.betaVersion !== process.env.EXPECTED_RELEASE_VERSION) {
throw new Error("unexpected metadata betaVersion: " + metadata.betaVersion);
}
} else if (metadata.channel === "preview") {
if (metadata.releaseVersion !== process.env.EXPECTED_RELEASE_VERSION) {
throw new Error("unexpected metadata releaseVersion: " + metadata.releaseVersion);
}
if (metadata.previewVersion !== process.env.EXPECTED_RELEASE_VERSION) {
throw new Error("unexpected metadata previewVersion: " + metadata.previewVersion);
}
const expectedPreviewNumber = Number(process.env.EXPECTED_RELEASE_VERSION.split("-preview.")[1]);
if (metadata.previewNumber !== expectedPreviewNumber) {
throw new Error("unexpected metadata previewNumber: " + metadata.previewNumber);
}
} else {
if (metadata.releaseVersion !== process.env.EXPECTED_RELEASE_VERSION) {
throw new Error("unexpected metadata releaseVersion: " + metadata.releaseVersion);

View file

@ -70,7 +70,23 @@ jobs:
required=true
fi
done
if [[ "$file" == "e2e/specs/mac.spec.ts" || "$file" == "e2e/specs/win.spec.ts" || "$file" == "e2e/specs/linux.spec.ts" || "$file" == "e2e/lib/linux-helpers.ts" || "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/release-beta.yml" || "$file" == ".github/workflows/release-stable.yml" ]]; then
if [[ "$file" == "apps/daemon/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
daemon_tests_required=true
fi
if [[ "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
web_tests_required=true
fi
if [[ "$file" == "scripts/"* || "$file" == "assets/"* || "$file" == "skills/"* || "$file" == "prompt-templates/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* ]]; then
daemon_tests_required=true
web_tests_required=true
fi
if [[ "$file" == "tools/dev/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
tools_dev_tests_required=true
fi
if [[ "$file" == "tools/pack/"* || "$file" == "apps/packaged/"* || "$file" == "apps/desktop/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
tools_pack_tests_required=true
fi
if [[ "$file" == "e2e/specs/mac.spec.ts" || "$file" == "e2e/specs/win.spec.ts" || "$file" == "e2e/specs/linux.spec.ts" || "$file" == "e2e/lib/linux-helpers.ts" || "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/release-beta.yml" || "$file" == ".github/workflows/release-preview.yml" || "$file" == ".github/workflows/release-stable.yml" ]]; then
required=true
daemon_tests_required=true
web_tests_required=true
@ -92,11 +108,13 @@ jobs:
tools_dev_tests_required=true
tools_pack_tests_required=true
fi
echo "required=$required" >> "$GITHUB_OUTPUT"
echo "daemon_tests_required=$daemon_tests_required" >> "$GITHUB_OUTPUT"
echo "web_tests_required=$web_tests_required" >> "$GITHUB_OUTPUT"
echo "tools_dev_tests_required=$tools_dev_tests_required" >> "$GITHUB_OUTPUT"
echo "tools_pack_tests_required=$tools_pack_tests_required" >> "$GITHUB_OUTPUT"
{
echo "required=$required"
echo "daemon_tests_required=$daemon_tests_required"
echo "web_tests_required=$web_tests_required"
echo "tools_dev_tests_required=$tools_dev_tests_required"
echo "tools_pack_tests_required=$tools_pack_tests_required"
} >> "$GITHUB_OUTPUT"
validate:
name: Validate workspace
@ -514,6 +532,11 @@ jobs:
if: ${{ needs.packaged_changes.outputs.required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 45
# v0.8.0 launch doc explicitly lists Linux as "coming soon" — the
# packaged Linux headless runtime is known-incomplete in this release.
# Keep the smoke job running so we keep collecting signal, but don't
# block PRs on it until the Linux client lands.
continue-on-error: true
steps:
- name: Checkout
@ -582,6 +605,28 @@ jobs:
mkdir -p "$report_dir"
pnpm test specs/linux.spec.ts 2>&1 | tee "$report_dir/vitest.log"
- name: Collect linux headless runtime logs
if: ${{ always() }}
run: |
set -euo pipefail
report_dir="$RUNNER_TEMP/packaged-report/linux-headless"
runtime_root="$RUNNER_TEMP/tools-pack/runtime/linux/namespaces/ci-pr-linux"
if [ -d "$runtime_root" ]; then
echo "--- $runtime_root tree ---"
find "$runtime_root" -maxdepth 4 -type f | head -40 || true
mkdir -p "$report_dir/runtime"
# Logs: per-app stdout/stderr captured by tools-pack
if [ -d "$runtime_root/logs" ]; then
cp -R "$runtime_root/logs" "$report_dir/runtime/logs" || true
fi
# Runtime state files (identity markers, sidecar JSON)
if [ -d "$runtime_root/runtime" ]; then
cp -R "$runtime_root/runtime" "$report_dir/runtime/state" || true
fi
else
echo "no runtime root found at $runtime_root"
fi
- name: Upload linux headless e2e spec report
if: ${{ always() }}
uses: actions/upload-artifact@v7

80
.github/workflows/docker-image.yml vendored Normal file
View file

@ -0,0 +1,80 @@
name: Docker image
# Phase 5 / spec §15.5 — multi-arch image builds.
#
# Pushes to ghcr.io on:
# - main branch → ghcr.io/<owner>/od:edge + :sha-<short>
# - tag (v*) → ghcr.io/<owner>/od:<tag> + :latest
#
# Pull requests build the image but do not push (smoke test only).
on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
branches: [main]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/od
# spec §15.1 tag scheme:
# - main → :edge + :sha-<short>
# - vX.Y.Z → :X.Y.Z + :latest
# - any branch → :branch-<name>
tags: |
type=ref,event=branch,suffix=-{{date 'YYYYMMDD-HHmmss' tz='UTC'}}
type=ref,event=tag
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha,prefix=sha-,format=short
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: deploy/Dockerfile
# spec §15.1 — multi-arch single manifest
platforms: linux/amd64,linux/arm64
# PR builds smoke-test the build only; merges to main /
# tags publish.
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# The in-tree Dockerfile uses node:24-alpine + apk for build
# tooling; we keep that default so the workflow doesn't drift
# from local builds. Spec §15.1 nominates bookworm-slim as
# the canonical base; switching is a follow-up that needs
# the Dockerfile's apk lines re-cast for apt.
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -190,7 +190,7 @@ jobs:
output="$RUNNER_TEMP/mac-framework-diagnostics.txt"
source_resolve_log="$RUNNER_TEMP/mac-framework-source-resolve.err"
source_framework="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const requireFromDesktop = createRequire(path.join(process.cwd(), "apps/desktop/package.json")); const electron = requireFromDesktop.resolve("electron"); process.stdout.write(path.join(path.dirname(electron), "dist", "Electron.app", "Contents", "Frameworks", "Electron Framework.framework"));' 2>"$source_resolve_log" || true)"
built_framework="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-beta/builder/mac-arm64/Open Design.app/Contents/Frameworks/Electron Framework.framework"
built_framework="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-beta/builder/mac-arm64/Open Design Beta.app/Contents/Frameworks/Electron Framework.framework"
dump_framework() {
local label="$1"
@ -376,7 +376,7 @@ jobs:
id: win_tools_pack_cache_key
shell: pwsh
env:
WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/contracts/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-beta.yml', '.github/scripts/release/cache/win.ps1') }}
WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/agui-adapter/**', 'packages/contracts/**', 'packages/plugin-runtime/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-beta.yml', '.github/scripts/release/cache/win.ps1') }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WIN_TOOLS_PACK_ORIGIN_KEY)) {
throw "Windows tools-pack cache origin key is empty"

View file

@ -4,18 +4,736 @@ on:
workflow_dispatch:
permissions:
actions: write
contents: read
concurrency:
group: open-design-release-preview
cancel-in-progress: false
env:
OPEN_DESIGN_TELEMETRY_RELAY_URL: ${{ vars.OPEN_DESIGN_TELEMETRY_RELAY_URL }}
# PostHog product-analytics ingest. Defined as repository secret + var
# so official builds ship with analytics enabled; PR builds and forks
# without these run the daemon in no-op analytics mode (events never
# leave the user's machine, /api/analytics/config returns enabled=false).
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
jobs:
placeholder:
name: Preview release placeholder
metadata:
name: Prepare preview metadata
runs-on: ubuntu-latest
env:
OPEN_DESIGN_PREVIEW_METADATA_URL: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}/preview/latest/metadata.json
outputs:
base_version: ${{ steps.preview.outputs.base_version }}
branch: ${{ steps.preview.outputs.branch }}
channel: ${{ steps.preview.outputs.channel }}
commit: ${{ steps.preview.outputs.commit }}
github_release_enabled: ${{ steps.preview.outputs.github_release_enabled }}
latest_stable: ${{ steps.preview.outputs.latest_stable }}
preview_number: ${{ steps.preview.outputs.preview_number }}
preview_version: ${{ steps.preview.outputs.preview_version }}
release_name: ${{ steps.preview.outputs.release_name }}
release_version: ${{ steps.preview.outputs.release_version }}
state_source: ${{ steps.preview.outputs.state_source }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Prepare preview release metadata
id: preview
run: node --experimental-strip-types ./scripts/release-preview.ts
- name: Validate R2 preview access
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
R2_ACCESS_PROBE_NAME: release-preview
RELEASE_CHANNEL: preview
run: bash .github/scripts/release/r2/check.sh
verify:
name: Verify build (typecheck + tests)
needs: metadata
runs-on: ubuntu-latest
steps:
- name: Explain placeholder
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build daemon and desktop (typecheck dependencies)
run: |
echo "release-preview is reserved for the preview release channel."
echo "The full build and publish implementation will land in a follow-up change."
pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop build
- name: Typecheck workspaces
run: pnpm -r --workspace-concurrency=1 --if-present run typecheck
- name: Check repository layout policies
run: pnpm guard
build_mac:
name: Build preview mac arm64
needs: [metadata, verify]
runs-on: macos-14
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify mac Electron framework symlinks
run: |
set -euo pipefail
electron_dist="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const requireFromDesktop = createRequire(path.join(process.cwd(), "apps/desktop/package.json")); const electron = requireFromDesktop.resolve("electron"); process.stdout.write(path.join(path.dirname(electron), "dist"));')"
framework="$electron_dist/Electron.app/Contents/Frameworks/Electron Framework.framework"
for link in \
"$framework/Electron Framework" \
"$framework/Helpers" \
"$framework/Libraries" \
"$framework/Resources" \
"$framework/Versions/Current"; do
if [ ! -L "$link" ]; then
echo "Expected Electron framework symlink, got non-symlink: $link" >&2
ls -la "$framework" >&2 || true
ls -la "$framework/Versions" >&2 || true
exit 1
fi
done
- name: Prepare Apple signing certificate
env:
APPLE_SIGNING_CERTIFICATE_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_BASE64 }}
APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
cert_path="$RUNNER_TEMP/open-design-signing.p12"
if ! printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 --decode > "$cert_path" 2>/dev/null; then
printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 -D > "$cert_path"
fi
{
echo "CSC_LINK=$cert_path"
echo "CSC_KEY_PASSWORD=$APPLE_SIGNING_CERTIFICATE_PASSWORD"
} >> "$GITHUB_ENV"
- name: Build preview mac artifacts
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
tools_pack_dir="$RUNNER_TEMP/tools-pack"
build_json_path="$RUNNER_TEMP/mac-tools-pack-build.json"
build_log_path="$RUNNER_TEMP/mac-tools-pack-build.log"
rm -rf "$tools_pack_dir"
: > "$build_log_path"
build_args=(
exec tools-pack mac build
--dir "$tools_pack_dir"
--namespace release-preview
--portable
--app-version "${{ needs.metadata.outputs.release_version }}"
--mac-compression normal
--to all
--json
--signed
)
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$build_log_path" >&2))"; then
printf '%s\n' "$build_output" | tee "$build_json_path"
else
build_status=$?
printf '%s\n' "$build_output"
exit "$build_status"
fi
- name: Capture mac framework diagnostics
if: ${{ failure() }}
continue-on-error: true
run: |
set -euo pipefail
output="$RUNNER_TEMP/mac-framework-diagnostics.txt"
source_resolve_log="$RUNNER_TEMP/mac-framework-source-resolve.err"
source_framework="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const requireFromDesktop = createRequire(path.join(process.cwd(), "apps/desktop/package.json")); const electron = requireFromDesktop.resolve("electron"); process.stdout.write(path.join(path.dirname(electron), "dist", "Electron.app", "Contents", "Frameworks", "Electron Framework.framework"));' 2>"$source_resolve_log" || true)"
built_framework="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-preview/builder/mac-arm64/Open Design Preview.app/Contents/Frameworks/Electron Framework.framework"
dump_framework() {
local label="$1"
local framework="$2"
echo "## $label"
echo "path=$framework"
if [ ! -e "$framework" ] && [ ! -L "$framework" ]; then
echo "missing"
return 0
fi
echo "### top-level"
ls -la "$framework" || true
echo "### symlinks"
find "$framework" -maxdepth 4 -type l -print0 | while IFS= read -r -d '' link; do
printf '%s -> %s\n' "$link" "$(readlink "$link")"
done || true
echo "### selected stat"
for path in \
"$framework" \
"$framework/Electron Framework" \
"$framework/Versions" \
"$framework/Versions/Current" \
"$framework/Versions/Current/Electron Framework" \
"$framework/Versions/A" \
"$framework/Versions/A/Electron Framework" \
"$framework/Resources" \
"$framework/Versions/A/Resources/Info.plist"; do
if [ -e "$path" ] || [ -L "$path" ]; then
stat -f '%Sp %HT %N' "$path" || true
else
echo "missing: $path"
fi
done
echo "### plist"
plutil -p "$framework/Versions/A/Resources/Info.plist" 2>&1 || true
echo "### codesign display"
codesign --display --verbose=4 "$framework/Electron Framework" 2>&1 || true
codesign --display --verbose=4 "$framework/Versions/Current/Electron Framework" 2>&1 || true
codesign --display --verbose=4 "$framework/Versions/A/Electron Framework" 2>&1 || true
codesign --display --verbose=4 "$framework" 2>&1 || true
}
{
date -u
if [ -n "$source_framework" ]; then
dump_framework "source Electron Framework" "$source_framework"
else
echo "## source Electron Framework"
echo "resolve failed"
cat "$source_resolve_log" || true
fi
dump_framework "built Electron Framework" "$built_framework"
} > "$output"
cat "$output"
- name: Upload mac build diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: open-design-preview-mac-build-diagnostics
path: |
${{ runner.temp }}/mac-tools-pack-build.log
${{ runner.temp }}/mac-tools-pack-build.json
${{ runner.temp }}/mac-framework-diagnostics.txt
if-no-files-found: warn
- name: Smoke preview mac packaged runtime
working-directory: e2e
env:
OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/mac-tools-pack-build.json
OD_PACKAGED_E2E_BUILD_LOG_PATH: ${{ runner.temp }}/mac-tools-pack-build.log
OD_PACKAGED_E2E_MAC: "1"
OD_PACKAGED_E2E_NAMESPACE: release-preview
OD_PACKAGED_E2E_RELEASE_CHANNEL: preview
OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/mac
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
run: |
set -euo pipefail
pnpm exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts
- name: Upload mac e2e spec report
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: open-design-preview-mac-e2e-report
path: ${{ runner.temp }}/release-report/mac
if-no-files-found: warn
- name: Prepare mac preview assets
env:
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_CHANNEL: preview
RELEASE_NOTES: Open Design Preview ${{ needs.metadata.outputs.release_version }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-preview
run: bash .github/scripts/release/assets/mac.sh
- name: Upload mac preview bundle
uses: actions/upload-artifact@v7
with:
name: open-design-preview-mac-release-assets
path: ${{ runner.temp }}/release-assets
build_mac_intel:
name: Build preview mac intel x64
needs: [metadata, verify]
runs-on: macos-15-intel
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prepare Apple signing certificate
env:
APPLE_SIGNING_CERTIFICATE_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_BASE64 }}
APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
cert_path="$RUNNER_TEMP/open-design-signing.p12"
if ! printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 --decode > "$cert_path" 2>/dev/null; then
printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 -D > "$cert_path"
fi
{
echo "CSC_LINK=$cert_path"
echo "CSC_KEY_PASSWORD=$APPLE_SIGNING_CERTIFICATE_PASSWORD"
} >> "$GITHUB_ENV"
- name: Build preview mac intel artifacts
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
pnpm exec tools-pack mac build \
--dir "$RUNNER_TEMP/tools-pack" \
--namespace release-preview-intel \
--portable \
--app-version "${{ needs.metadata.outputs.release_version }}" \
--mac-compression normal \
--to all \
--json \
--signed
- name: Prepare mac intel preview assets
env:
RELEASE_CHANNEL: preview
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-preview-intel
run: bash .github/scripts/release/assets/mac-intel.sh
- name: Upload mac intel preview bundle
uses: actions/upload-artifact@v7
with:
name: open-design-preview-mac-intel-release-assets
path: ${{ runner.temp }}/release-assets
build_win:
name: Build preview win x64
needs: [metadata, verify]
runs-on: windows-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Compute Windows tools-pack cache key
id: win_tools_pack_cache_key
shell: pwsh
env:
WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/agui-adapter/**', 'packages/contracts/**', 'packages/plugin-runtime/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-preview.yml', '.github/scripts/release/cache/win.ps1') }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WIN_TOOLS_PACK_ORIGIN_KEY)) {
throw "Windows tools-pack cache origin key is empty"
}
$prefix = "tools-pack-win-v7-preview-$env:RUNNER_OS-"
"origin=$env:WIN_TOOLS_PACK_ORIGIN_KEY" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"prefix=$prefix" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"key=$prefix$env:WIN_TOOLS_PACK_ORIGIN_KEY" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Restore Windows tools-pack cache
id: win_tools_pack_cache_restore
uses: actions/cache/restore@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/tools-pack-cache
key: ${{ steps.win_tools_pack_cache_key.outputs.key }}
restore-keys: |
${{ steps.win_tools_pack_cache_key.outputs.prefix }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup NSIS
shell: pwsh
run: |
if ((Get-Command makensis.exe -ErrorAction SilentlyContinue) -or (Test-Path "C:\Program Files (x86)\NSIS\makensis.exe")) {
exit 0
}
choco install nsis -y --no-progress
- name: Build preview windows artifacts
id: win_tools_pack_build
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$toolsPackDir = "${{ runner.temp }}/tools-pack"
$cacheDir = "${{ runner.temp }}/tools-pack-cache"
$buildJsonPath = Join-Path $env:RUNNER_TEMP "windows-tools-pack-build.json"
$buildArgs = @(
"exec", "tools-pack", "win", "build",
"--dir", $toolsPackDir,
"--cache-dir", $cacheDir,
"--namespace", "release-preview-win",
"--portable",
"--app-version", "${{ needs.metadata.outputs.release_version }}",
"--to", "nsis",
"--json"
)
"cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
try {
$buildOutput = pnpm @buildArgs
if ($LASTEXITCODE -ne 0) {
throw "Windows tools-pack cached build exited with code $LASTEXITCODE"
}
} catch {
Write-Warning "Windows tools-pack cached build failed; removing restored cache and retrying without cache."
"cache_failed=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $cacheDir
$buildOutput = pnpm exec tools-pack win build `
--dir $toolsPackDir `
--namespace release-preview-win `
--portable `
--app-version "${{ needs.metadata.outputs.release_version }}" `
--to nsis `
--json
if ($LASTEXITCODE -ne 0) {
throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE"
}
}
$buildOutput | Set-Content -Path $buildJsonPath
$buildOutput
- name: Delete failed Windows tools-pack cache
if: ${{ steps.win_tools_pack_build.outputs.cache_failed == 'true' && steps.win_tools_pack_cache_restore.outputs.cache-matched-key != '' }}
shell: pwsh
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
$matchedKey = "${{ steps.win_tools_pack_cache_restore.outputs.cache-matched-key }}"
$caches = @(gh cache list --key $matchedKey --limit 100 --json id,key,ref | ConvertFrom-Json | Where-Object { $_.key -eq $matchedKey })
foreach ($cache in $caches) {
gh cache delete $cache.id
}
"deletedFailedCacheKey=$matchedKey count=$($caches.Count)"
- name: Smoke preview windows packaged runtime
working-directory: e2e
env:
OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/windows-tools-pack-build.json
OD_PACKAGED_E2E_WIN: "1"
OD_PACKAGED_E2E_NAMESPACE: release-preview-win
OD_PACKAGED_E2E_RELEASE_CHANNEL: preview
OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/win
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
run: |
$ErrorActionPreference = "Stop"
pnpm exec tsx scripts/release-smoke.ts win specs/win.spec.ts
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
- name: Upload windows e2e spec report
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: open-design-preview-win-e2e-report
path: ${{ runner.temp }}/release-report/win
if-no-files-found: warn
- name: Prune Windows tools-pack cache
shell: pwsh
continue-on-error: true
run: ./.github/scripts/release/cache/win.ps1
- name: Save Windows tools-pack cache
if: ${{ success() && (steps.win_tools_pack_cache_restore.outputs.cache-hit != 'true' || steps.win_tools_pack_build.outputs.cache_failed == 'true') }}
uses: actions/cache/save@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/tools-pack-cache
key: ${{ steps.win_tools_pack_cache_key.outputs.key }}
- name: Retain recent Windows tools-pack caches
if: ${{ success() }}
shell: pwsh
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
$prefix = "${{ steps.win_tools_pack_cache_key.outputs.prefix }}"
$keep = 3
$caches = @(gh cache list --key $prefix --sort created_at --order desc --limit 100 --json id,key,createdAt | ConvertFrom-Json)
$stale = @($caches | Select-Object -Skip $keep)
foreach ($cache in $stale) {
gh cache delete $cache.id
}
"actionsCachePrefix=$prefix kept=$([Math]::Min($caches.Count, $keep)) deleted=$($stale.Count)"
- name: Prepare windows preview assets
shell: pwsh
env:
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_CHANNEL: preview
RELEASE_NOTES: Open Design Preview ${{ needs.metadata.outputs.release_version }}
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-preview-win
run: ./.github/scripts/release/assets/win.ps1
- name: Upload windows preview bundle
uses: actions/upload-artifact@v7
with:
name: open-design-preview-win-release-assets
path: ${{ runner.temp }}/release-assets
build_linux:
name: Build preview linux x64
needs: [metadata, verify]
if: ${{ vars.ENABLE_STABLE_LINUX == 'true' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build preview linux artifacts
run: |
set -euo pipefail
pnpm exec tools-pack linux build \
--dir "$RUNNER_TEMP/tools-pack" \
--namespace release-preview-linux \
--portable \
--app-version "${{ needs.metadata.outputs.release_version }}" \
--to appimage \
--containerized \
--json
- name: Prepare linux preview assets
env:
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
TOOLS_PACK_NAMESPACE: release-preview-linux
run: bash .github/scripts/release/assets/linux.sh
- name: Upload linux preview bundle
uses: actions/upload-artifact@v7
with:
name: open-design-preview-linux-release-assets
path: ${{ runner.temp }}/release-assets
publish:
name: Publish preview release
needs:
- metadata
- verify
- build_mac
- build_mac_intel
- build_win
- build_linux
if: >-
${{
always() &&
!cancelled() &&
needs.metadata.result == 'success' &&
needs.verify.result == 'success' &&
needs.build_mac.result == 'success' &&
needs.build_mac_intel.result == 'success' &&
needs.build_win.result == 'success' &&
(needs.build_linux.result == 'success' || needs.build_linux.result == 'skipped')
}}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
BASE_VERSION: ${{ needs.metadata.outputs.base_version }}
BRANCH_NAME: ${{ needs.metadata.outputs.branch }}
ENABLE_LINUX: ${{ needs.build_linux.result == 'success' }}
ENABLE_MAC: "true"
ENABLE_MAC_INTEL: "true"
ENABLE_WIN: "true"
GITHUB_RELEASE_ENABLED: "false"
MAC_INTEL_SIGNED: "true"
RELEASE_CHANNEL: preview
RELEASE_NAME: ${{ needs.metadata.outputs.release_name }}
RELEASE_SIGNED: "true"
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
REPORT_MODE: zip
STATE_SOURCE: ${{ needs.metadata.outputs.state_source }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Download mac preview bundle
uses: actions/download-artifact@v8
with:
name: open-design-preview-mac-release-assets
path: ${{ runner.temp }}/release-assets/mac
- name: Download mac intel preview bundle
uses: actions/download-artifact@v8
with:
name: open-design-preview-mac-intel-release-assets
path: ${{ runner.temp }}/release-assets/mac-intel
- name: Download windows preview bundle
uses: actions/download-artifact@v8
with:
name: open-design-preview-win-release-assets
path: ${{ runner.temp }}/release-assets/win
- name: Download linux preview bundle
if: ${{ needs.build_linux.result == 'success' }}
uses: actions/download-artifact@v8
with:
name: open-design-preview-linux-release-assets
path: ${{ runner.temp }}/release-assets/linux
- name: Download mac e2e spec report
uses: actions/download-artifact@v8
with:
name: open-design-preview-mac-e2e-report
path: ${{ runner.temp }}/release-report/mac
- name: Download windows e2e spec report
uses: actions/download-artifact@v8
with:
name: open-design-preview-win-e2e-report
path: ${{ runner.temp }}/release-report/win
- name: Publish preview assets and metadata to R2
id: r2
run: bash .github/scripts/release/r2/publish.sh
- name: Verify R2 preview publish
env:
R2_LINUX_APPIMAGE_URL: ${{ steps.r2.outputs.linux_appimage_url }}
R2_MAC_DMG_URL: ${{ steps.r2.outputs.mac_dmg_url }}
R2_MAC_FEED_URL: ${{ steps.r2.outputs.mac_feed_url }}
R2_MAC_INTEL_DMG_URL: ${{ steps.r2.outputs.mac_intel_dmg_url }}
R2_MAC_INTEL_ZIP_URL: ${{ steps.r2.outputs.mac_intel_zip_url }}
R2_MAC_ZIP_URL: ${{ steps.r2.outputs.mac_zip_url }}
R2_METADATA_URL: ${{ steps.r2.outputs.metadata_url }}
R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }}
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
run: bash .github/scripts/release/r2/verify.sh
- name: Publish summary
env:
R2_LINUX_APPIMAGE_URL: ${{ steps.r2.outputs.linux_appimage_url }}
R2_MAC_DMG_URL: ${{ steps.r2.outputs.mac_dmg_url }}
R2_MAC_FEED_URL: ${{ steps.r2.outputs.mac_feed_url }}
R2_MAC_INTEL_DMG_URL: ${{ steps.r2.outputs.mac_intel_dmg_url }}
R2_MAC_INTEL_ZIP_URL: ${{ steps.r2.outputs.mac_intel_zip_url }}
R2_MAC_ZIP_URL: ${{ steps.r2.outputs.mac_zip_url }}
R2_METADATA_URL: ${{ steps.r2.outputs.metadata_url }}
R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }}
R2_VERSION_METADATA_URL: ${{ steps.r2.outputs.version_metadata_url }}
R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }}
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
run: bash .github/scripts/release/r2/summary.sh
- name: Cleanup workflow artifacts
if: ${{ success() }}
run: bash .github/scripts/release/github/cleanup-artifacts.sh

View file

@ -440,7 +440,7 @@ jobs:
id: win_tools_pack_cache_key
shell: pwsh
env:
WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/contracts/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-stable.yml', '.github/scripts/release/cache/win.ps1') }}
WIN_TOOLS_PACK_ORIGIN_KEY: ${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'apps/daemon/**', 'apps/web/**', 'apps/desktop/**', 'apps/packaged/**', 'packages/agui-adapter/**', 'packages/contracts/**', 'packages/plugin-runtime/**', 'packages/sidecar-proto/**', 'packages/sidecar/**', 'packages/platform/**', 'tools/pack/bin/**', 'tools/pack/package.json', 'tools/pack/resources/**', 'tools/pack/src/**', 'tools/pack/tsconfig.json', 'assets/community-pets/**', 'assets/frames/**', 'craft/**', 'design-systems/**', 'prompt-templates/**', 'skills/**', '.github/workflows/release-stable.yml', '.github/scripts/release/cache/win.ps1') }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WIN_TOOLS_PACK_ORIGIN_KEY)) {
throw "Windows tools-pack cache origin key is empty"

View file

@ -51,6 +51,14 @@ This file is the single source of truth for agents entering this repository. Rea
- Do not add root aggregate `pnpm build` or `pnpm test` aliases. Build/test commands must stay package-scoped (`pnpm --filter <package> ...`) or tool-scoped (`pnpm tools-pack ...` / `pnpm tools-pr ...`).
- Do not add root e2e aliases; e2e package commands and ownership rules live in `e2e/AGENTS.md`.
## Release channel model
- `beta` is the daily R&D/development validation channel. It is optimized for fast development feedback and is not part of the stable promotion gate.
- `nightly` is the internal validation channel for stable delivery. Stable releases remain gated by validated nightly artifacts.
- `preview` is an independent early-access channel with stable-like release rigor. It should use preview versions such as `X.Y.Z-preview.N`, publish to the `preview` R2 channel, publish updater feeds under `preview/latest`, and follow stable's platform policy including the existing optional Linux enablement.
- `stable` is the formal delivery channel. Do not make stable promotion depend on preview; stable continues to depend on nightly only.
- Public packaged app identity must stay channel-distinct: stable uses `Open Design`, beta uses `Open Design Beta`, and preview uses `Open Design Preview`. Do not ship beta or preview mac DMGs whose drag-install app bundle is `Open Design.app`.
## Boundary constraints
- Tests under `apps/`, `packages/`, and `tools/` live in a package/app/tool-level `tests/` directory sibling to `src/`; keep `src/` source-only and do not add new `*.test.ts` or `*.test.tsx` files under `src/`. Playwright UI automation belongs to `e2e/ui/`, not app packages.

View file

@ -151,6 +151,106 @@ A memory-plus-UI release: **auto-memory store** carries agent context across run
- Expand entry and settings automation coverage. ([#954])
- Refreshed generated GitHub metrics SVG and contributors wall. ([#1115], [#1117], [#1183], [#1188], [#1328], [#1330])
- **Plugin & marketplace system — Phase 2A + 1 + 1.5 + 2B + 2C entry slice + 3 (full) + 4 (full incl. OD_BUNDLED_ATOM_PROMPTS default ON) + 5 (full incl. live S3 impl; postgres adapter still stubbed) + 6 (full incl. asset rasterisation) + 7 (all six code-migration atom impls landed; full pipeline e2e covered by smoke test) + 8 (full incl. GenUI \u2192 decision bridge + handoff promotion ladder bridge) + bundled scenarios + bundled-scenario fallback resolver.** Spec: [`docs/plugins-spec.md`](docs/plugins-spec.md). Living plan: [`docs/plans/plugins-implementation.md`](docs/plans/plugins-implementation.md).
- **`od plugin events purge` admin escape hatch (Phase 4).** New `purgePluginEventBuffer()` returns pre-purge stats `{ purged, firstId, lastId, preNextId }` so an operator can audit what was discarded. Loopback-only `POST /api/plugins/events/purge` route + `od plugin events purge --confirm` CLI subcommand (refuses to run without `--confirm` so a stray invocation never drops audit data accidentally).
- **`od plugin manifest <id>` + `od plugin sources` (Phase 4).** New CLI subcommands for plugin authors and ops: `od plugin manifest <id>` prints just the parsed manifest JSON (no wrapper, no fsPath / installedAt noise) so authors can diff against their on-disk `open-design.json`. `od plugin sources` lists every distinct `(sourceKind, source)` tuple + plugin count, sorted by descending count then alphabetically, with the per-bucket plugin list sorted by id. New `pluginSourceBuckets()` pure helper backs the CLI for byte-deterministic output.
- **`od plugin events snapshot/stats` + tail filters (Phase 4).** Extends §3.II1 with: `GET /api/plugins/events/snapshot` for non-SSE one-shot reads (dashboards that don't want a live connection); `GET /api/plugins/events/stats` returns a `summarisePluginEvents()` rollup (counts byKind, byPluginId — skipping empty ids — plus oldest/newest at + id range); `--kind <k>` and `--plugin-id <id>` filter flags work on both `od plugin events tail` (client-side post-render) and the new `od plugin events snapshot` subcommand. CLI pretty-prints the stats rollup with sorted-key counts for byte-determinism.
- **More plugin event producer hooks (Phase 4).** Extends §3.II1 with: `installPlugin` accepts `eventKind: 'installed' | 'upgraded'` so the upgrade route distinguishes the operation in the live tail; `POST /api/plugins/:id/trust` emits `plugin.trust-changed`; `POST /api/applied-plugins/prune` emits `plugin.snapshot-pruned` when anything was actually removed; `POST /api/marketplaces/:id/refresh` emits `plugin.marketplace-refreshed`. Each hook is best-effort and never blocks the underlying mutation if the ring buffer throws.
- **Plugin event ring buffer + SSE tail (Phase 4).** New `apps/daemon/src/plugins/events.ts` ships an in-memory FIFO ring buffer (capped at 1000 entries, monotonic ids, fan-out subscribers) for plugin lifecycle events: `plugin.installed` / `.upgraded` / `.uninstalled` / `.trust-changed` / `.applied` / `.snapshot-pruned` / `.marketplace-refreshed`. Producer hooks landed on the installer (install + uninstall). New `GET /api/plugins/events` SSE route emits the backlog on connect (with optional `?since=<id>` trim) then forwards live events. CLI: `od plugin events tail [-f] [--since <id>] [--json]` — non-follow mode drains backlog + exits; `-f` keeps the stream open for ops dashboards.
- **`od plugin doctor --strict` + verify strict propagation (Phase 4).** New `--strict` flag on `od plugin doctor` promotes warnings to failures (exit 4 distinguishes 'strict failed' from doctor errors at exit 1). The `verifyPlugin()` orchestrator gains a matching `strict: true` config knob that flows through `.od-verify.json` so plugins can lock 'no warnings allowed' as a one-line CI policy.
- **`od daemon db verify` SQLite integrity check (Phase 5).** New `verifySqliteIntegrity()` pure helper wraps PRAGMA `integrity_check` (or `quick_check` with `--quick`) + PRAGMA `foreign_key_check`. Returns a structured `{ ok, mode, issues[], elapsedMs, generatedAt }` report with issues tagged `kind='integrity' | 'foreign_key'`. Loopback-only `POST /api/daemon/db/verify` route + `od daemon db verify [--quick]` CLI subcommand — exit 0 on ok=true, 4 on any issue, so CI can wire it into a pre-deploy check.
- **`od daemon db vacuum` (Phase 5).** New loopback-only `POST /api/daemon/db/vacuum` runs SQLite VACUUM and reports before/after sizes + reclaimed bytes + elapsed ms. Useful after large delete batches (snapshot prune, plugin uninstall) shrink rows but leave space allocated to the file. CLI: `od daemon db vacuum [--json]`.
- **`od daemon db status` SQLite inventory (Phase 5).** New `inspectSqliteDatabase()` pure helper + `GET /api/daemon/db` route returns a structured report: `kind` ('sqlite'), file location, size on disk (primary + WAL + SHM), schema version (`user_version` PRAGMA), and per-table row counts (system tables excluded, lexicographic order). CLI: `od daemon db status [--json]` lets ops sanity-check deployments at a glance + compare expected-vs-actual table rosters across daemon upgrades.
- **`od plugin verify <id>` CI meta-command (Phase 4).** New `verifyPlugin()` pure orchestrator aggregates `doctor` + `simulate` + `canon --check` into one pass/fail report. Reads `<plugin-folder>/.od-verify.json` (or `--config <path>`) so plugin authors commit their CI checks into their repo. Each check resolves to `passed | failed | skipped | unsupported`; aggregate passes iff every enabled check is passed or skipped (`unsupported` bubbles up as a fail to keep CI honest). One-liner CI workflow: `od plugin verify my-plugin` — exit 0 on pass, 4 on fail, 2 on CLI/config error.
- **`od plugin simulate <id>` pipeline dry-run (Phase 4).** New `simulatePipeline({ pipeline, signals, iterationCap? })` pure helper walks every stage in a plugin's pipeline against caller-supplied signals (constant snapshot OR per-iteration generator function) and reports `outcome ∈ { single | converged | cap | unparsable }` per stage plus aggregate `outcome ∈ { all-converged | all-single | mixed | cap-hit | unparsable }`. Companion `parseSignalKv()` parses repeatable `-s key=value` CLI flags into the closed `UntilSignals` vocabulary with typo guards. CLI: `od plugin simulate <pluginId> [-s key=value ...] [--cap <n>] [--json]` — exit 4 on cap-hit/unparsable so CI can hook this into a pipeline check.
- **`od plugin stats` inventory health report (Phase 4).** New `pluginInventoryStats()` + `snapshotInventoryStats()` pure helpers aggregate installed-plugin counts (by `sourceKind` / `trust` / `taskKind`, bundled vs. third-party split, plugins with elevated capabilities — `fs:write` / `subprocess` / `bash` / `network` / `connector:*`) and snapshot health (status breakdown, project / run linkage, oldest / newest applied timestamps). New `GET /api/plugins/stats` route + `od plugin stats [--json]` CLI subcommand for at-a-glance fleet audit.
- **`od plugin canon --check <expected-file>` byte-equality fixtures (Phase 4).** New `--check` mode on `od plugin canon` compares the canon output against an on-disk fixture and exits 4 on mismatch with a per-line diff preview. Lets plugin authors commit `renderPluginBlock()` regression fixtures into their own `tests/` without writing a fresh test harness.
- **`od plugin canon <snapshotId>` show prompt block (Phase 4).** New `GET /api/applied-plugins/:snapshotId/canon` route renders the canonical `## Active plugin` / `## Plugin inputs` block this snapshot splices into the system prompt, byte-equal to what the agent reads via `composeSystemPrompt`. Plain-text response when the request sends `Accept: text/plain` so shell pipes work cleanly; JSON wrapper otherwise. CLI: `od plugin canon <snapshotId> [--json]`.
- **`od plugin snapshots show / diff` debugging (Phase 4).** New `GET /api/applied-plugins/<id>`-backed `od plugin snapshots show <snapshotId>` dumps a full `AppliedPluginSnapshot` for inspection. New `diffSnapshots()` pure helper + `od plugin snapshots diff <a> <b>` compares two snapshots field-by-field with a `digestEqual` flag that surfaces the e2e-2 invariance check at-a-glance. Compares identity, inputs map, capabilities, resolvedContext, connectors, mcpServers, genuiSurfaces, pipeline (stages + per-stage atoms / until), assets. Entries sort lexicographically so output is byte-deterministic.
- **`od plugin diff <a> <b>` author tooling (Phase 4).** New `diffPlugins({ a, b })` pure-helper compares two `InstalledPluginRecord` values and returns a structured `{ pluginId?, entries[], added, removed, changed }` report. Compares top-level fields (id, version, sourceKind, source, trust, capabilitiesGranted) plus manifest body (title / version / description / license / tags / kind / taskKind / mode / capabilities / inputs / context.skills / context.craft / context.assets / pipeline.stages + per-stage atoms / connectors.required / genui.surfaces). Collection diffs collapse to `<n> added, <m> removed` summaries; entries sort lexicographically by field path so the report is byte-deterministic across re-runs. CLI: `od plugin diff <id-a> <id-b> [--json]` renders +/-/~ glyphs.
- **`od atoms info <id>` + atom catalog drift fix (Phase 4).** New `GET /api/atoms/:id` returns catalog metadata + the bundled SKILL.md body. Catalog promoted nine atoms (`code-import`, `design-extract`, `figma-extract`, `token-map`, `rewrite-plan`, `patch-edit`, `diff-review`, `handoff`) from `status='planned'` to `'implemented'` to reflect the daemon impls landed across §3.N§3.S; `build-test` added as a missing catalog row. Net: zero `planned` atoms remaining.
- **`od plugin upgrade <id>` re-install from recorded source (Phase 4).** New `POST /api/plugins/:id/upgrade` route streams the same SSE shape as `/install`, internally calling `installPlugin()` against the plugin's recorded `source` string (so the github / https / local-folder byte path replays end-to-end). 409s with `code='bundled-plugin'` when the plugin shipped bundled with the daemon (those upgrade with the daemon image), and `code='missing-source'` when the recorded source is empty. CLI: `od plugin upgrade <id>` — exit 0 on success, 1 on installer error.
- **patch-edit atomic file writes (Phase 7 safety patch).** Every `fs.writeFile` inside `patch-edit` (file content + `plan/steps.json` + `plan/receipts/`) now routes through an `atomicWriteFile()` helper that writes to a sibling tmp file and renames into place via POSIX `rename(2)`. A daemon crash mid-write can no longer leave the source file truncated; rejection-path failures (context mismatch, etc.) leave the original byte-equal. Cleanup path unlinks the orphan tmp on failure so disk noise stays bounded.
- **`od plugin search <query>` + filters on `od plugin list` (Phase 4).** New `searchInstalledPlugins({ plugins, query?, taskKind?, mode?, tag?, trust?, bundled? })` pure-helper ranks free-text matches across id / title / description / tags (exact wins over substring; tag-exact ranks ahead of title-substring) and AND-combines with structural filters. CLI gains `od plugin search <query>` and adds `--task-kind / --mode / --tag / --trust / --bundled / --no-bundled` knobs to `od plugin list`. Search results show `matched=[…]` per row so the user can trace which field caught the hit.
- **`od plugin pack <folder>` distribution archive (Phase 4).** New `packPlugin({ folder, out? })` helper builds a gzip-compressed tar archive of an author's plugin folder, ready to install via the installer's HTTPS-tarball path. Default output path is `<basename>-<manifest.version>.tgz` beside the folder. Skiplist matches the installer's extract-time exclusions (`node_modules`, `.git`, `dist`, `build`, `out`, `coverage`, `.turbo`, `.cache`, `.pnpm-store`, `.DS_Store`, etc). Symlinks rejected at both walk and tar-filter passes so what authors pack matches what the installer accepts. The output archive itself is always excluded from its own contents (no spiral when `--out` lands inside the folder).
- **`od plugin validate <folder>` author-side lint (Phase 4).** Pre-install lint pass against an unfinished plugin folder. New `validatePluginFolder({ folder, registry? })` helper reads the folder via the same `resolvePluginFolder()` the installer uses (manifest parsing is byte-equal to install time) and runs `doctorPlugin()` against the supplied registry view. Surfaces atom-id mismatches, manifest parse errors, unparseable `until` expressions, and unresolved skill / DS / craft refs without dirtying the registry table. CLI takes `--no-daemon` for fully-offline runs and `--json` for machine-readable output. Exit code `4` when `doctor.ok=false`, `2` on CLI/folder errors, `0` on clean.
- **`OD_BUNDLED_ATOM_PROMPTS` default flipped to ON (Phase 4).** With every Phase 6/7/8 atom impl shipping its own SKILL.md fragment (build-test, code-import, design-extract, figma-extract, token-map, rewrite-plan, patch-edit, diff-review, handoff), the audit-block on flipping the default is lifted. Runs with a plugin snapshot now ALWAYS get the matching `## Active stage` blocks spliced into the system prompt without operators having to set the env var. Set `OD_BUNDLED_ATOM_PROMPTS=0` to opt out (snapshot replay against pre-flip daemons, regression bisects that need byte-equal prompts). Runs without a snapshot stay byte-equal so the change is invisible outside the plugin path.
- **`S3ProjectStorage` live implementation via AWS SigV4 (Phase 5).** New `apps/daemon/src/storage/aws-sigv4.ts` ships a minimal AWS Signature V4 signer using only `node:crypto` (no `@aws-sdk/*` dependency \u2014 keeps the daemon shippable as a small binary). The signer is verifiably correct against the AWS-published reference vector (GetObject example). `S3ProjectStorage` now implements all five `ProjectStorage` ops (read / write / list / delete / stat) via `globalThis.fetch`, with virtual-host-style URLs by default and path-style for `OD_S3_ENDPOINT` overrides (Aliyun OSS, Tencent COS, Huawei OBS, MinIO). LIST walks `NextContinuationToken` for paginated buckets. Credentials read from `OD_S3_ACCESS_KEY_ID/SECRET/SESSION_TOKEN` first, falling back to `AWS_*` env vars so existing IAM-role / `aws configure` setups drop in unchanged.
- **`runHandoffAtom()` pipeline-driven bridge (Phase 8 entry slice).** New helper reads the canonical state previous atoms wrote (`<cwd>/review/decision.json` from diff-review, `<cwd>/critique/build-test.json` from build-test) and returns the updated `ArtifactManifest` with the right `handoffKind` / `exportTargets[]` attached. Promotion ladder (spec §11.5.1): `reject` \u2192 `design-only`; `accept`/`partial` no-build-test \u2192 `implementation-plan`; + `build.passing`\u2228`tests.passing` \u2192 `patch`; + both signals + docker/cli export \u2192 `deployable-app`. Monotonicity enforced via `recordHandoff()`; a manifest carrying `'patch'` won't demote to `'design-only'` even when a follow-up reject arrives.
- **`runAndPersistHandoff()` + auto-handoff from diff-review GenUI bridge (Phase 8 entry slice).** New on-disk shell round-trips `<cwd>/handoff/manifest.json`: reads existing manifest (or falls back to `manifestSeed`, then to a minimal default), calls `runHandoffAtom()`, and writes back only when something changed (`persistMode: 'created' | 'updated' | 'skipped'`). The diff-review GenUI bridge now auto-invokes this after recording the user's decision, so the Phase 8 promotion ladder closes end-to-end without an agent turn: user clicks Accept all in the web composer → daemon writes `review/decision.json` AND `handoff/manifest.json` with the right `handoffKind` set; failure surfaces on the bridge result as `handoffError` without regressing the diff-review write.
- **figma-migration pipeline e2e smoke (Phase 6).** New `plugins-figma-migration-e2e.test.ts` walks figma-extract (stubbed REST) → token-map → diff-review → handoff end-to-end. Locks the scenario folder roster (`plugins/_official/scenarios/od-figma-migration/open-design.json` stages: extract → tokens → generate → critique).
- **Full code-migration pipeline e2e smoke test (Phase 7-8).** First integration test that walks every code-migration atom (code-import \u2192 design-extract \u2192 token-map \u2192 rewrite-plan \u2192 patch-edit \u2192 build-test \u2192 diff-review \u2192 handoff) on a Next.js fixture repo without any agent in the loop. Locks the inter-atom file contract so a future PR can't break the chain by silently renaming `code/tokens.json` or adding a required field to `plan/steps.json` without updating every downstream reader. Ends on `handoffKind='deployable-app'` for the happy path; second case verifies the `reject` ladder rung still demotes through to `design-only`.
- **Earlier in this changeset:**
- **diff-review GenUI \u2192 `review/decision.json` bridge (Phase 8 entry slice).** New `apps/daemon/src/plugins/atoms/diff-review-genui-bridge.ts` owns the `__auto_diff_review_` prefix detection, strict JSON validation of the surface payload (rejects non-object payloads + unknown decisions; coerces non-string entries out of the file lists; forwards optional `reason`), and the end-to-end `applyDiffReviewDecisionToCwd({ cwd, value, reviewer })` glue. `POST /api/runs/:runId/genui/:surfaceId/respond` now bridges the choice surface response into `runDiffReview()` so the user's decision lands on `<cwd>/review/decision.json` immediately. Best-effort: failures are surfaced on the response payload as `diffReviewBridge: { ok: false, error }` without regressing the GenUI respond contract.
- **Earlier in this changeset:**
- **Native diff-review UI on `GenUISurfaceRenderer` (Phase 8 entry slice).** New `DiffReviewChoiceSurface` component renders the auto-derived `__auto_diff_review_<stageId>` choice surface natively: three top-level buttons (Accept all / Reject all / Partial…), an optional notes textarea forwarded as `decision.reason`, and a per-file accept/reject checklist that appears when the user picks Partial. Local validation refuses Partial submission when any touched file is left undecided (mirrors the daemon's `partial must cover every touched file` contract). New `GenericChoiceSurface` renders any schema with a primary enum property as a button group; property literally named `decision` wins so plugin-author-customised diff-review schemas keep rendering as accept/reject/partial buttons. PendingSurface gains an optional `context: { touchedFiles?: [] }` field so future runtime context (per-stage hints) can plug in without bloating `GenUISurfaceSpec`.
- **`figma-extract` asset rasterisation second pass (Phase 6 entry slice).** `runFigmaExtract({ offlineAssets: false })` now downloads per-leaf-node assets via the Figma `GET /v1/images` endpoint. New knobs: `assetFormat` (`'svg' | 'png' | 'jpg' | 'pdf'`, default svg), `assetMaxBytes` (default 5 MiB). 50-id chunks, per-id failure isolation (a single CDN hiccup doesn't lose the batch), every issue lands in `meta.unsupportedNodes[]` with `reason='asset-too-large' | 'no download URL returned' | 'download <status>' | 'image fetch error: …' | 'figma error: …'`. `atomDigest` is recomputed after the asset pass settles so the digest reflects which assets succeeded.
- **Earlier in this changeset:**
- **`token-map` atom (Phase 6/7 entry slice).** New `runTokenMap({ cwd, source?, designSystem, strict? })` writes `<cwd>/token-map/{colors,typography,spacing,radius,shadow,unmatched,meta}.json`. Match strategies (deterministic, first-win): exact value match → normalised hex (#abc → #aabbcc) → fuzzy name (strips `--` / `ds-` / `odds-` / `theme-` prefixes). Unmatched lands with `'no-target-equivalent'`, `'target-collision'`, or `'invalid-source'`. `parseDesignSystemTokens(body)` heuristic helper lifts CSS custom properties + markdown table rows from a DESIGN.md body so daemon callers don't need a hand-curated bag.
- **`figma-extract` atom (Phase 6 entry slice).** New `runFigmaExtract({ cwd, fileUrl?, fileKey?, token, fetchFn?, offlineAssets? })` walks Figma's REST `GET /v1/files/<key>` into the canonical `<cwd>/figma/{tree,tokens,meta}.json`. Token lift heuristics: SOLID fills + strokes → colours, `cornerRadius` → radius, FRAME / GROUP heights → spacing candidates. Gradient + image fills land in `meta.unsupportedNodes[]` with a reason. `fetchFn` is pluggable (tests inject stubs); offline mode keeps the assets/ directory empty. The OAuth bearer token is forwarded via the `Authorization` header and never persisted by the runner — the daemon's connector-gate stays the only place that touches the token store.
- **Auto-derived `choice` surface for `diff-review` (Phase 8 entry slice).** Mirrors the connector-gate's auto `oauth-prompt` pattern. When a stage in the EFFECTIVE pipeline (consumer-declared OR scenario-fallback) lists `diff-review`, applyPlugin() synthesises an implicit `choice` GenUI surface (`__auto_diff_review_<stageId>`, `persist='run'`, 24h timeout, `onTimeout='abort'`) so the user can accept / reject / partial without the plugin author having to declare the surface by hand. Plugin-author-declared surfaces with the same id win.
- **Earlier in this changeset:**
- **Bundled-scenario pipeline fallback (spec §23.3.3).** New pure helper `resolveAppliedPipeline({ manifest, scenarios })` returns `{ pipeline, source: 'declared'|'scenario'|'none', scenarioId? }`. `applyPlugin()` now consults the bundled scenarios surfaced by `loadPluginRegistryView()` and copies the matching scenario's pipeline verbatim into both the `ApplyResult.pipeline` and `AppliedPluginSnapshot.pipeline`. Scenario plugins themselves never recurse. `manifestSourceDigest` stays unchanged across the fallback so spec §11.5 e2e-2 invariance holds. Only rows with `source_kind='bundled'` AND `od.kind='scenario'` are eligible — third-party scenarios cannot promote themselves.
- **`design-extract` atom (Phase 6/7 entry slice).** New `runDesignExtract({ cwd, repoPath })` reads `code/index.json`, walks every scannable file (css/scss/ts/tsx/js/jsx/html/json), and writes the canonical token bag at `<cwd>/code/tokens.json`. Captures hex / rgba / hsla colours, CSS custom properties (`--*-color/-bg/-accent/-primary/-secondary/-surface/-border/-muted`), font-family declarations, px/rem/em spacing on padding/margin/gap/inset, border-radius, box-shadow, and Tailwind config quoted hex palette entries. Each token records `sources[]` (`<path>:<line>`) + `usage[]` so `token-map` has a precise audit trail.
- **`rewrite-plan` atom (Phase 7 entry slice).** New `runRewritePlan({ cwd, intent? })` produces `<cwd>/plan/{plan.md, ownership.json, steps.json, meta.json}`. Heuristic ownership classifier maps every imported file to a tier (`leaf | shared | route | shell`); the step generator emits one `rewrite-<slug>` per leaf component, bundles sibling stylesheets, prepends a `tokens-alignment` step when design-extract surfaced literals, marks shared/route touches `medium` risk, and always closes with a `build-test` step. `meta.atomDigest` is over a canonicalised view of `code/index.json` so re-walks that don't change the file roster don't invalidate the plan.
- **`patch-edit` atom (Phase 7 entry slice).** New `applyPatchForStep({ cwd, stepId, diff })`, `skipStep()`, and `readPlanProgress()`. The applier parses unified-diff text (plain edits, `/dev/null` creation, `/dev/null` deletion, multi-file hunks) and enforces the spec §20.3 safety contract before any byte is written: (a) path-traversal guard, (b) every touched file MUST appear in `step.files[]`, (c) `shell`-tier files require `step.risk='high'`, (d) context lines must match exactly so stale patches surface `context mismatch at line N` without mutating disk. After a successful apply, the runner updates `plan/steps.json[id].status='completed'` and writes `plan/receipts/step-<id>.json` with files / added / removed / rationale.
- **`diff-review` atom (Phase 7-8 entry slice).** New `runDiffReview({ cwd, decision? })` reads `plan/receipts/` and emits `<cwd>/review/{diff.patch, summary.md, decision.json, meta.json}`. Decision composition rules: `accept` defaults `accepted_files` to every touched file; `reject` defaults `rejected_files` to every touched file; `partial` MUST cover every touched file via the `accepted_files rejected_files` union or the runner throws `missing <file>` so the GenUI surface can re-prompt. `decision.json` is round-trippable so a re-run without an explicit decision returns the persisted choice.
- **Earlier in this changeset:**
- **`build-test` atom (Phase 7 entry slice).** New `runBuildTest({ cwd, buildCommand?, testCommand? })` shells out to typecheck + test commands (overrides win; otherwise inferred from `package.json` scripts). Auto-detects pnpm / yarn / bun / npm from lockfile presence. Per-command timeout default 5 min, log budget 1 MiB. Emits `build.passing` + `tests.passing` signals (newly added to spec §22.4 vocabulary) plus the legacy `critique.score`. `writeBuildTestReport()` persists `critique/build-test.json` + `critique/build-test.log`.
- **`code-import` atom (Phase 7 entry slice).** New `runCodeImport({ repoPath, cwd })` walks a real repo and writes a normalised `<cwd>/code/index.json` snapshot. Honours a 60 s walk budget; skips `node_modules`, `.git`, `dist`, `build`, `coverage`, etc.; rejects symlinks. Detects framework (next / vite / remix / astro / sveltekit / cra / custom), package manager, style system, and routing model.
- **`handoff` atom + `ArtifactManifest` provenance fields (Phase 7-8 entry slice).** `ArtifactManifest` gains the spec §11.5.1 reserved fields (`sourcePluginSnapshotId`, `sourcePluginId`, `sourceTaskKind`, `parentArtifactId`, `artifactKind`, `renderKind`, `handoffKind`, `exportTargets[]`, `deployTargets[]`). New `recordHandoff()` helper enforces append-only `exportTargets` / `deployTargets` and monotonic `handoffKind` promotion (`design-only` < `implementation-plan` < `patch` < `deployable-app`). `isDeployableAppEligible()` centralises the §11.5.1 promotion rule (`build.passing` + `tests.passing` + at least one docker / cli export target). Contracts barrel `index.ts` switched to `.js`-suffixed re-exports so daemon NodeNext resolution picks every type up end-to-end.
- **Bundled scenario plugins (spec §23.3.3).** Four `od.kind: 'scenario'` plugins under `plugins/_official/scenarios/{od-new-generation,od-figma-migration,od-code-migration,od-tune-collab}/` lock the default reference pipeline per `taskKind`. Replacing a default for an enterprise / vertical edition is now a content edit rather than a daemon code change. The bundled boot walker registers them via the existing tier layout.
- **Plugin & marketplace system — earlier landing.** Spec: [`docs/plugins-spec.md`](docs/plugins-spec.md). Living plan: [`docs/plans/plugins-implementation.md`](docs/plans/plugins-implementation.md).
- **`OD_SNAPSHOT_RETENTION_DAYS` referenced-row TTL (PB2).** `pruneExpiredSnapshots` now retires referenced snapshot rows whose project has been deleted AND whose `applied_at` is older than the configured window. Live-project rows stay pinned forever (reproducibility wins). The GC worker reads `readPluginEnvKnobs().snapshotRetentionDays` so the env-var contract spec PB2 reserved is now end-to-end.
- **`OD_BUNDLED_ATOM_PROMPTS=1` activates `composeDaemonSystemPrompt`'s atom-block path.** When set AND the run carries an applied snapshot with a non-empty `od.pipeline.stages[*]`, the daemon walks each stage, calls `loadAtomBodies` + `renderActiveStageBlock`, and threads the result as `composeSystemPrompt({ activeStageBlocks })`. Default behaviour (flag unset) is byte-equal to today's prompt.
- **Phase 6 / 7 / 8 atom SKILL.md substrate.** Nine new `plugins/_official/atoms/<atom>/{SKILL.md, open-design.json}` pairs the bundled boot walker registers on startup: `figma-extract`, `token-map` (Phase 6); `code-import`, `design-extract`, `rewrite-plan`, `patch-edit`, `diff-review`, `build-test` (Phase 7); `handoff` (Phase 8). The fragments teach the agent what each (planned) atom expects so a plugin author who references one of these ids in `od.pipeline.stages[*].atoms[]` sees the canonical fragment without a doctor warning. The matching shell-out implementations stay scheduled per spec §16 Phase 6 / 7 / 8.
- **Plugin & marketplace system — earlier landing.** Spec: [`docs/plugins-spec.md`](docs/plugins-spec.md). Living plan: [`docs/plans/plugins-implementation.md`](docs/plans/plugins-implementation.md).
- **Per-cloud Helm value overrides.** `tools/pack/helm/open-design/values-{aws,gcp,azure,aliyun,tencent,huawei,self}.yaml` ship the volume + ingress diffs spec §15.5 enumerates. Operators install with `helm install od ./tools/pack/helm/open-design -f values-aws.yaml`.
- **`composeSystemPrompt({ activeStageBlocks })`.** Both daemon and contracts composers accept a pre-rendered list of `## Active stage` blocks (produced by `renderActiveStageBlock` + `loadAtomBodies`). Substrate slice for the §23.3.2 prompt-fragment migration; the actual call-site wiring stays gated on the next phase so default behaviour is byte-equal to today's prompt.
- **Plugin-bundled component surface.** `GenUISurfaceRenderer` mounts a `sandbox="allow-scripts"` iframe at `/api/plugins/:id/asset/<path>` when a surface declares `od.genui.surfaces[].component`. Communication is one-way via `postMessage({ kind: 'genui:respond', surfaceId, value })`. The daemon-side asset endpoint serves files from `installed_plugins.fs_path` under the §9.2 preview CSP (`default-src 'none'; connect-src 'none'; frame-ancestors 'self'`) plus `X-Content-Type-Options: nosniff`.
- **`ProjectStorage` + `DaemonDb` adapter substrate.** New `apps/daemon/src/storage/` module ships the Phase 5 §15.6 interface contracts. `LocalProjectStorage` (v1 default) is fully wired and tested; `S3ProjectStorage` is an interface-locked stub that throws on every op until the AWS SDK wiring lands. `resolveDaemonDbConfig({})` parses `OD_DAEMON_DB` / `OD_PG_*` env vars but the SQLite path remains the only reachable backend in v1.
- **Plugin & marketplace system — earlier landing.** Spec: [`docs/plugins-spec.md`](docs/plugins-spec.md). Living plan: [`docs/plans/plugins-implementation.md`](docs/plans/plugins-implementation.md).
- **Phase 5 bound-API-token guard.** `startServer()` refuses to bind a non-loopback `OD_BIND_HOST` without `OD_API_TOKEN`; bearer middleware on `/api/*` rejects non-loopback peers without `Authorization: Bearer <OD_API_TOKEN>`. `/api/health`, `/api/version`, `/api/daemon/status` stay open so monitoring probes (kubelet, Compose) work without secrets.
- **Helm chart templates.** `tools/pack/helm/open-design/templates/` ships Deployment, Service, Secret, ConfigMap, two PVCs, optional Ingress, plus _helpers.tpl + NOTES.txt. The chart installs end-to-end with `helm install od ./tools/pack/helm/open-design --set secrets.apiToken=$(openssl rand -hex 32)`.
- **`od.genui.surfaces[].component`.** `GenUISurfaceSpecSchema` accepts a `{ path, export?, sandbox? }` field; `genui:custom-component` joins `KNOWN_TOP_LEVEL_CAPABILITIES`; `doctorPlugin()` flags the missing-capability + path-traversal cases. The component path is the v1 substrate for spec §10.3.5 alignment-roadmap row 2; the web sandbox loader stays scheduled.
- **`.github/workflows/docker-image.yml`.** Multi-arch (linux/amd64 + linux/arm64) build + push to ghcr.io: `:edge` on main, `:<version>` + `:latest` on tag, `:sha-<short>` on every push, smoke build on PRs. Authenticates via GITHUB_TOKEN with `packages:write`.
- **Plugin & marketplace system — earlier landing.** Spec: [`docs/plugins-spec.md`](docs/plugins-spec.md). Living plan: [`docs/plans/plugins-implementation.md`](docs/plans/plugins-implementation.md).
- **`@open-design/agui-adapter` workspace package + `GET /api/runs/:runId/agui`.** Pure-TS bidirectional bridge between OD's native `PersistedAgentEvent` / `GenUIEvent` / `PluginPipelineStageEvent` union and the [AG-UI canonical event protocol](https://github.com/CopilotKit/CopilotKit). The new SSE endpoint mirrors `/api/runs/:id/events` but pipes every record through `encodeOdEventForAgui` so a CopilotKit / AG-UI client consumes an OD run unmodified. v1 plugins need no change to be consumable inside the AG-UI ecosystem (spec §10.3.5).
- **`renderActiveStageBlock` + `loadAtomBodies`.** Substrate slice for spec §23.3.2 patch 2: the daemon-side helper reads `<bundled-fsPath>/SKILL.md` for any registered bundled atom and the contracts-side renderer assembles a `## Active stage: <id>` block. The `composeSystemPrompt()` rewiring that consumes them is the next PR; today the helpers are reachable, tested, and the bundled atom plugins from §3.I3 already ship the matching SKILL.md bodies.
- **Phase 5 Dockerfile + docker-compose + Helm chart entry slice.** `deploy/Dockerfile` now bundles `plugins/_official/` so `registerBundledPlugins()` finds the atom set inside the container. `tools/pack/docker-compose.yml` is the canonical hosted-mode manifest (two-volume layout, OD_API_TOKEN, /api/daemon/status healthcheck). `tools/pack/helm/open-design/` pins the Helm chart parameter surface for the per-cloud value overrides spec §15.5 enumerates; templates land in the Phase 5 follow-up PR.
- **Plugin & marketplace system — earlier landing.** Spec: [`docs/plugins-spec.md`](docs/plugins-spec.md). Living plan: [`docs/plans/plugins-implementation.md`](docs/plans/plugins-implementation.md).
- **Pipeline runner wired into `POST /api/runs`.** Plugin runs whose snapshot carries `od.pipeline.stages[*]` now emit `pipeline_stage_started` synchronously before the agent process spawns. Subsequent stage events (`pipeline_stage_completed`, per-stage `run_devloop_iterations` audit rows, devloop convergence) fire asynchronously while the agent runs. Stage runner is a converging stub (`critique.score=4`, `preview.ok=true`) so single-pass pipelines walk through every stage in O(stages) time; loop stages still respect `OD_MAX_DEVLOOP_ITERATIONS`. Errors surface as `pipeline_stage_failed` events and never block the agent. **e2e-3** flips from entry-slice to the full §8 contract: `apps/daemon/tests/plugins-headless-run.test.ts` asserts the first SSE event on a pipeline-bearing plugin run is `pipeline_stage_started`.
- **`od doctor`.** Repo-wide diagnostics: daemon status, installed-plugin doctor sweep, library inventory (skills / design-systems / atoms / craft). Exits 1 when any plugin doctor returns ok=false; exit 64 when the daemon is unreachable.
- **`od config get/set/list/unset`.** Wraps `GET/PUT /api/app-config`. Top-level keys via positional or `--value`; nested values via `--value-json`.
- **Phase 4 / §23 entry slice — bundled atom plugins.** New `plugins/_official/atoms/{discovery-question-form,todo-write,direction-picker,critique-theater}/{SKILL.md,open-design.json}` pairs, plus a daemon boot walker (`apps/daemon/src/plugins/bundled.ts`) that registers each folder under `source_kind='bundled'` / `trust='bundled'` on every startup. Idempotent (upserts), ENOENT-silent (works outside the dev tree). Lays the substrate for the §23.3.2 patch that lifts prompt fragments out of `system.ts`.
- **Plugin & marketplace system — Phase 2A + 1 + 1.5 + 2B + 2C entry slice + 3 (full) + 4 (scaffold / export / publish / atoms doc / library CLI) + early 5 (earlier landing).** Spec: [`docs/plugins-spec.md`](docs/plugins-spec.md). Living plan: [`docs/plans/plugins-implementation.md`](docs/plans/plugins-implementation.md).
- **Phase 4 publish.** `od plugin publish <id> --to anthropics-skills|awesome-agent-skills|clawhub|skills-sh [--repo <github>] [--open]` builds the catalog submission URL + PR body and (with `--open`) auto-launches the system browser. The author still goes through the upstream review flow; OD never POSTs anywhere.
- **CLI parity remainder.** `od atoms list/show`, `od skills list/show`, `od design-systems list/show`, `od craft list/show`, `od status`, `od version`. New HTTP routes `GET /api/craft` + `GET /api/craft/:id` walk the `craft/` directory and return `{ id, label, bytes }` summaries.
- **`od marketplace search "<query>" [--tag <tag>]`.** Substring match over every configured marketplace's `plugins[]`; powered entirely by the catalog metadata `od marketplace add` already cached, so a code agent can discover plugins without being inside the desktop UI.
- **Plugin & marketplace system — Phase 2A + 1 + 1.5 + 2B (composer mount + marketplace deep UI) + 2C entry slice + 3 entry slice + 4 (scaffold / export / atoms doc) + early 5 (earlier landing).** Spec: [`docs/plugins-spec.md`](docs/plugins-spec.md). Living plan: [`docs/plans/plugins-implementation.md`](docs/plans/plugins-implementation.md).
- **Phase 4 author tooling.** `od plugin scaffold --id <id>` writes a starter SKILL.md + open-design.json + README.md (optionally a `.claude-plugin/plugin.json`). `od plugin export <projectId> --as od|claude-plugin|agent-skill --out <dir>` materialises a publish-ready folder from the AppliedPluginSnapshot behind a project (or a snapshot id), so any chat that ran a plugin can be re-published to anthropics/skills, awesome-agent-skills, clawhub, or skills.sh without leaving the terminal. The output's open-design.json carries a provenance block (snapshotId + manifestSourceDigest + appliedAt) that reverse-resolves to the originating run.
- **Phase 2B marketplace deep UI.** New routes `/marketplace` and `/marketplace/<pluginId>` rendered by `MarketplaceView` (catalog grid + trust filters + configured catalogs panel) and `PluginDetailView` (manifest, capability checklist, connector requirements, declared GenUI surfaces, "Use this plugin" → applyPlugin → Home). `/plugins/<id>` is a parsed alias so the public-site URL scheme reserved in spec §13 already works in-app.
- **Phase 2B ChatComposer mount.** `ChatComposer` renders `<PluginsSection variant="strip" />` above the input whenever a `projectId` is known. Apply hydrates the draft only when empty so a mid-typing user is never overwritten.
- **`docs/atoms.md`.** New canonical reference for the first-party atom catalog: implemented vs planned ids, task-kind mapping, how the daemon resolves an atom at run time, the closed `until`-signal vocabulary, and the §22.5 community-plugin → first-party-atom promotion path.
- **Plugin & marketplace system — Phase 2A finished + Phase 1 follow-up + Phase 1.5 + Phase 2B (composer mount) + Phase 2C entry slice + Phase 3 entry slice + early Phase 5 (earlier landing).** Spec: [`docs/plugins-spec.md`](docs/plugins-spec.md). Living plan: [`docs/plans/plugins-implementation.md`](docs/plans/plugins-implementation.md).
- **CLI canonical agent-facing API (spec §11.7).** Every UI action now has a CLI equivalent: `od project create/list/info/delete`, `od run start/watch/cancel/list/info` (with `--plugin`, `--inputs`, `--grant-caps`, `--snapshot-id`, `--follow`), `od files list/read/write/upload/delete`, `od conversation list/info`. Plugin runs route through the §3.A1 snapshot resolver.
- **Phase 1.5 headless lifecycle.** New `od daemon start [--headless] [--serve-web] [--port] [--host]`, `od daemon status [--json]`, `od daemon stop`. Backed by new HTTP routes `GET /api/daemon/status` and `POST /api/daemon/shutdown` (loopback-only). The default `od` (no subcommand) keeps its desktop behaviour for back-compat. e2e-3's headless install→project→run loop is now anchored in a daemon supertest (`apps/daemon/tests/plugins-headless-run.test.ts`).
- **Phase 3 marketplace plugin install resolution.** `POST /api/plugins/install` with a bare plugin name walks every configured `plugin_marketplaces` row in registration order and re-routes to the canonical `github:…` / `https://…` source recorded in the matched manifest. CLI: `od plugin install <name>` works against any catalog the operator added via `od marketplace add <url>`.
- **Web composer mount.** New `PluginsSection` widget combines `InlinePluginsRail`, `ContextChipStrip`, `PluginInputsForm`, plus `renderPluginBriefTemplate`'s `{{var}}` substitution. Mounted in `NewProjectPanel` below the project-name input as the Phase 2A discovery surface. Purely additive: the existing Send button rules are unchanged.
- The earlier work in this changelog block remains in place (snapshot resolver, trust mutation, connector tool-token gate, fallback rejection, GitHub tarball + HTTPS install sources, pipeline runner with cross-conversation cache, marketplace registry, snapshot GC worker, web component primitives, definition-of-done suite, …).
- **Plugin & marketplace system — Phase 2A → entry slice of Phase 2B/2C/3 (earlier landing).** Spec: [`docs/plugins-spec.md`](docs/plugins-spec.md). Living plan: [`docs/plans/plugins-implementation.md`](docs/plans/plugins-implementation.md).
- Snapshot resolver wires `applyPlugin()` into `POST /api/projects` and `POST /api/runs`. Capability gate failures map to HTTP 409 / exit 66; missing inputs map to HTTP 422 / exit 67. The in-memory run object carries `appliedPluginSnapshotId` so connector / replay paths read a frozen view.
- Trust mutation: new `POST /api/plugins/:id/trust` endpoint plus matching `od plugin trust <id> --capabilities …` CLI. Validates the spec §5.3 capability vocabulary (rejects unknown / malformed strings); preserves the implicit `prompt:inject` floor on revoke.
- Connector tool-token gate: tokens minted for plugin runs carry `pluginSnapshotId` / `pluginTrust` / `pluginCapabilitiesGranted`. `/api/tools/connectors/execute` re-validates the §5.3 `connector:<id>` rule per call (CONNECTOR_NOT_GRANTED 403 on miss). Trusted plugins implicitly carry `connector:*`; restricted plugins must list each id explicitly.
- API-fallback rejection: every `/api/proxy/*` entry returns 409 PLUGIN_REQUIRES_DAEMON when a body smuggles `pluginId` / `appliedPluginSnapshotId`.
- Snapshot GC worker enforces the PB2 `expires_at` TTL (`OD_SNAPSHOT_UNREFERENCED_TTL_DAYS` / `OD_SNAPSHOT_GC_INTERVAL_MS`) at boot + on a periodic interval. Operator escape hatch: `od plugin snapshots prune --before <ts>`.
- Installer accepts `github:owner/repo[@ref][/subpath]` (codeload tarball) and `https://*.tar.gz` archives in addition to local folders. Hard guards: symlink / hard-link rejection, path-traversal segments, 50 MiB size cap.
- Pipeline runner emits `pipeline_stage_*` events per stage and persists every iteration into `run_devloop_iterations`. Pre-stage GenUI surfaces auto-derived for not-yet-connected required connectors hit the cross-conversation cache (e2e-5: a second conversation in the same project never re-broadcasts an already-resolved `oauth-prompt`).
- Marketplace registry minimum verbs: `od marketplace add/list/info/refresh/remove/trust` plus the matching HTTP routes. Default trust tier is `restricted` per spec §9; the Phase 3 follow-up wires `od plugin install <name>` resolution + the trust UI.
- Web composer surface: `applyPlugin()` state helper, `InlinePluginsRail`, `ContextChipStrip`, `PluginInputsForm`, `GenUISurfaceRenderer` (confirmation + oauth-prompt first-class; form / choice fall back to a JSON Schema preview until Phase 2A.5), `GenUIInbox` drawer.
- `od plugin run` apply→start shorthand. CLI structured error helper maps recoverable HTTP 4xx envelopes to the §12.4 exit codes (6473) so code agents get a stable retry contract.
- Plan §8 e2es covered at the daemon level: e2e-2 (pure apply across runs), e2e-4 (replay invariance after plugin upgrade via `renderPluginBlock(snapshot)`), e2e-5 (GenUI cross-conversation cache), e2e-6 (connector trust gate; re-validated independently in the token-issuance + execute routes), e2e-7 (API-fallback rejection at every proxy entry), e2e-8 (apply purity regression: 100 applies → 0 FS mutation, +100 snapshot rows). e2e-3 (headless run) stays scheduled for Phase 1.5.
- **`ib-pitch-book` skill** — investment-banking strategic-alternatives pitch book (Anthropic financial-services Pitch Agent workflow); ships `example.html` and IB layout references.
## [0.6.0] - 2026-05-09
A connectivity-and-iteration release: Open Design becomes a fully bidirectional MCP citizen (external MCP client with 39 templates), ships **Cloudflare Pages deployment** for generated artifacts (with custom domains), advances Critique Theater to **Phase 6** (interrupt + project-keyed run registry), and lands a redesigned top bar, draggable file tabs, batch delete, **vector PDF export**, **agent-callable research/search**, and **Orbit activity summaries**. Hyperframes learns the HTML-in-Canvas API. New BYOK provider (Ollama Cloud), new agent capabilities (Gemini 3 preview + GPT-5.1 codex picker + DeepSeek v4), new design systems (BMW M, Slack, Cisco, Webex, Mission Control, Urdu Modern), eight new skill bundles, and Turkish + Thai locales. 136 merged PRs since 0.5.0.

View file

@ -29,12 +29,15 @@
"dev": "pnpm run build && node dist/cli.js --no-open",
"start": "pnpm run build && node dist/cli.js",
"test": "vitest run -c vitest.config.ts",
"typecheck": "pnpm --filter @open-design/contracts build && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
"typecheck": "pnpm --filter @open-design/contracts build && pnpm --filter @open-design/registry-protocol build && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"@open-design/agui-adapter": "workspace:*",
"@open-design/contracts": "workspace:*",
"@open-design/platform": "workspace:*",
"@open-design/plugin-runtime": "workspace:*",
"@open-design/registry-protocol": "workspace:*",
"@open-design/sidecar": "workspace:*",
"@open-design/sidecar-proto": "workspace:*",
"@opentelemetry/api": "^1.9.0",
@ -46,6 +49,7 @@
"multer": "^2.1.1",
"posthog-node": "^4.18.0",
"prom-client": "^15.1.0",
"tar": "^7.5.13",
"undici": "^7.16.0"
},
"devDependencies": {
@ -53,6 +57,7 @@
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^20.17.10",
"@types/tar": "^6.1.13",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
},

View file

@ -16,6 +16,49 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import path from 'node:path';
// Plugin-system env knobs. See docs/plans/plugins-implementation.md F6 / F9.
// Phase 1 only reads them; the GC worker that enforces snapshot expiry lands
// in Phase 5. Centralized here to keep daemon modules from sprinkling magic
// numbers across the codebase.
export interface PluginEnvKnobs {
// Hard ceiling on devloop iterations per stage (spec §10.2).
maxDevloopIterations: number;
// Days before an unreferenced applied_plugin_snapshots row expires. A
// value of 0 means "keep forever" (operators can opt out of GC entirely).
snapshotUnreferencedTtlDays: number;
// Optional cap on how long even a referenced snapshot stays around once
// its run/conversation/project is terminal. Default unset -> unlimited.
snapshotRetentionDays: number | null;
// GC worker tick interval. Phase 5 reads this; Phase 1 just exposes the
// knob through `od config get` so operators can plan ahead.
snapshotGcIntervalMs: number;
}
function intFromEnv(key: string, fallback: number): number {
const raw = process.env[key];
if (typeof raw !== 'string' || raw.trim().length === 0) return fallback;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
return Math.floor(parsed);
}
function nullableIntFromEnv(key: string): number | null {
const raw = process.env[key];
if (typeof raw !== 'string' || raw.trim().length === 0) return null;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return Math.floor(parsed);
}
export function readPluginEnvKnobs(): PluginEnvKnobs {
return {
maxDevloopIterations: intFromEnv('OD_MAX_DEVLOOP_ITERATIONS', 10),
snapshotUnreferencedTtlDays: intFromEnv('OD_SNAPSHOT_UNREFERENCED_TTL_DAYS', 30),
snapshotRetentionDays: nullableIntFromEnv('OD_SNAPSHOT_RETENTION_DAYS'),
snapshotGcIntervalMs: intFromEnv('OD_SNAPSHOT_GC_INTERVAL_MS', 6 * 60 * 60 * 1000),
};
}
export interface AgentModelPrefs {
model?: string;
reasoning?: string;

View file

@ -48,6 +48,25 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
} = ctx.critique;
const { validateBaseUrl } = ctx.validation;
const isDaemonShuttingDown = ctx.lifecycle?.isDaemonShuttingDown ?? (() => false);
const rejectProxyPluginContext = (body: Record<string, unknown>, res: any) => {
if (
(typeof body.pluginId === 'string' && body.pluginId.trim().length > 0) ||
(
typeof body.appliedPluginSnapshotId === 'string' &&
body.appliedPluginSnapshotId.trim().length > 0
)
) {
sendApiError(
res,
409,
'PLUGIN_REQUIRES_DAEMON',
'Plugin runs must go through POST /api/runs so the daemon can resolve and pin the applied plugin snapshot.',
);
return true;
}
return false;
};
app.post('/api/runs', (req, res) => {
if (isDaemonShuttingDown()) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
@ -671,6 +690,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
app.post('/api/proxy/anthropic/stream', async (req, res) => {
/** @type {Partial<ProxyStreamRequest>} */
const proxyBody = req.body || {};
if (rejectProxyPluginContext(proxyBody, res)) return;
const { baseUrl, apiKey, model, systemPrompt, messages, maxTokens } =
proxyBody;
if (!baseUrl || !apiKey || !model) {
@ -766,6 +786,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
app.post('/api/proxy/openai/stream', async (req, res) => {
/** @type {Partial<ProxyStreamRequest>} */
const proxyBody = req.body || {};
if (rejectProxyPluginContext(proxyBody, res)) return;
const { baseUrl, apiKey, model, systemPrompt, messages, maxTokens } =
proxyBody;
if (!baseUrl || !apiKey || !model) {
@ -861,6 +882,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
app.post('/api/proxy/azure/stream', async (req, res) => {
/** @type {Partial<ProxyStreamRequest>} */
const proxyBody = req.body || {};
if (rejectProxyPluginContext(proxyBody, res)) return;
const { baseUrl, apiKey, model, systemPrompt, messages, maxTokens, apiVersion } =
proxyBody;
if (!baseUrl || !apiKey || !model) {
@ -973,6 +995,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
app.post('/api/proxy/google/stream', async (req, res) => {
/** @type {Partial<ProxyStreamRequest>} */
const proxyBody = req.body || {};
if (rejectProxyPluginContext(proxyBody, res)) return;
const { baseUrl, apiKey, model, systemPrompt, messages, maxTokens } = proxyBody;
if (!apiKey || !model) {
return sendApiError(
@ -1071,6 +1094,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
app.post('/api/proxy/ollama/stream', async (req, res) => {
const proxyBody = req.body || {};
if (rejectProxyPluginContext(proxyBody, res)) return;
const { baseUrl, apiKey, model, systemPrompt, messages, maxTokens } = proxyBody;
if (!apiKey || !model) {
return sendApiError(res, 400, 'BAD_REQUEST', 'apiKey and model are required');

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@ import net from 'node:net';
import type { Express, Request, RequestHandler, Response } from 'express';
import type { ToolTokenGrant } from '../tool-tokens.js';
import { checkConnectorAccess, type ToolTokenGrant } from '../tool-tokens.js';
import { validateBoundedJsonObject } from '../live-artifacts/schema.js';
import { executeConnectorTool, listConnectorTools } from '../tools/connectors.js';
import { readComposioConfig, readPublicComposioConfig, writeComposioConfig } from './composio-config.js';
@ -15,6 +15,7 @@ type ConnectorApiErrorCode =
| 'VALIDATION_FAILED'
| 'CONNECTOR_NOT_FOUND'
| 'CONNECTOR_NOT_CONNECTED'
| 'CONNECTOR_NOT_GRANTED'
| 'CONNECTOR_DISABLED'
| 'CONNECTOR_TOOL_NOT_FOUND'
| 'CONNECTOR_SAFETY_DENIED'
@ -758,6 +759,18 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
options.sendApiError(res, 400, 'BAD_REQUEST', 'toolName is required');
return;
}
// Plan §3.A3 / spec §9: re-validate the plugin connector capability
// gate on every call so a token replacement attack never bypasses
// the §5.3 rule. When the grant has no plugin context the gate is
// a no-op.
const connectorGate = checkConnectorAccess(grant, connectorId);
if (!connectorGate.ok) {
options.sendApiError(res, 403, 'CONNECTOR_NOT_GRANTED', connectorGate.reason, {
details: { connectorId },
});
return;
}
const inputValidation = validateBoundedJsonObject(input ?? {}, 'input');
if (!inputValidation.ok) {
options.sendApiError(res, 400, 'VALIDATION_FAILED', inputValidation.error, {

View file

@ -1,38 +1,31 @@
import {
APP_KEYS,
OPEN_DESIGN_SIDECAR_CONTRACT,
SIDECAR_DEFAULTS,
SIDECAR_ENV,
SIDECAR_MESSAGES,
type DaemonStatusSnapshot,
} from "@open-design/sidecar-proto";
import { requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar";
import { requestJsonIpc } from "@open-design/sidecar";
export const MCP_DEFAULT_DAEMON_URL = "http://127.0.0.1:7456";
export const DEFAULT_DAEMON_URL = "http://127.0.0.1:7456";
export interface ResolveMcpDaemonUrlOptions {
export interface ResolveDaemonUrlOptions {
/** Value passed via `--daemon-url`. Empty string is treated as unset. */
flagUrl?: string | null;
/** Defaults to `process.env`; injected for tests. */
env?: NodeJS.ProcessEnv;
/** IPC discovery timeout. Short by default so an absent daemon does not stall MCP startup. */
/** IPC discovery timeout. Short by default so an absent daemon does not stall CLI startup. */
timeoutMs?: number;
}
/**
* Resolve the daemon HTTP base URL for `od mcp`.
* Resolve the daemon HTTP base URL for `od` client commands.
*
* Spawn order: explicit `--daemon-url` flag, `OD_DAEMON_URL` env, then
* a STATUS roundtrip to the sidecar IPC socket the running daemon
* already publishes (`/tmp/open-design/ipc/<namespace>/daemon.sock`).
* Falls back to the legacy default for direct `od` launches that do
* not run as a sidecar. Discovery means the install snippet never has
* to bake a port: every spawn rediscovers the live URL, so an
* ephemeral daemon port (tools-dev, packaged) cannot invalidate a
* previously-installed MCP client config.
* a STATUS roundtrip to the concrete sidecar IPC endpoint supplied by
* the lifecycle owner in `OD_SIDECAR_IPC_PATH`. Falls back to the
* legacy default for direct `od` launches that do not run as a sidecar.
*/
export async function resolveMcpDaemonUrl(
options: ResolveMcpDaemonUrlOptions = {},
export async function resolveDaemonUrl(
options: ResolveDaemonUrlOptions = {},
): Promise<string> {
const env = options.env ?? process.env;
const flagUrl = options.flagUrl ?? null;
@ -41,20 +34,16 @@ export async function resolveMcpDaemonUrl(
if (envUrl != null && envUrl.length > 0) return envUrl;
const discovered = await discoverDaemonUrlFromIpc(env, options.timeoutMs ?? 800);
if (discovered != null) return discovered;
return MCP_DEFAULT_DAEMON_URL;
return DEFAULT_DAEMON_URL;
}
async function discoverDaemonUrlFromIpc(
env: NodeJS.ProcessEnv,
timeoutMs: number,
): Promise<string | null> {
const socketPath = env[SIDECAR_ENV.IPC_PATH];
if (socketPath == null || socketPath.length === 0) return null;
try {
const socketPath = resolveAppIpcPath({
app: APP_KEYS.DAEMON,
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
env,
namespace: env[SIDECAR_ENV.NAMESPACE] ?? SIDECAR_DEFAULTS.namespace,
});
const status = await requestJsonIpc<DaemonStatusSnapshot>(
socketPath,
{ type: SIDECAR_MESSAGES.STATUS },

View file

@ -10,6 +10,7 @@ import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { migrateCritique } from './critique/persistence.js';
import { migrateMediaTasks } from './media-tasks.js';
import { migratePlugins } from './plugins/persistence.js';
type SqliteDb = Database.Database;
type DbRow = Record<string, any>;
@ -260,6 +261,7 @@ function migrate(db: SqliteDb): void {
}
migrateCritique(db);
migrateMediaTasks(db);
migratePlugins(db);
}
// ---------- deployments ----------
@ -422,6 +424,7 @@ const PROJECT_COLS = `id, name, skill_id AS skillId,
design_system_id AS designSystemId,
pending_prompt AS pendingPrompt,
metadata_json AS metadataJson,
applied_plugin_snapshot_id AS appliedPluginSnapshotId,
custom_instructions AS customInstructions,
created_at AS createdAt,
updated_at AS updatedAt`;
@ -575,6 +578,7 @@ function normalizeProject(row: DbRow) {
designSystemId: row.designSystemId,
pendingPrompt: row.pendingPrompt ?? undefined,
metadata,
appliedPluginSnapshotId: row.appliedPluginSnapshotId ?? undefined,
customInstructions: row.customInstructions ?? undefined,
createdAt: Number(row.createdAt),
updatedAt: Number(row.updatedAt),

View file

@ -0,0 +1,110 @@
// GenUI event types + payload helpers (spec §10.3.2). Joins the existing
// `PersistedAgentEvent` SSE / ND-JSON channel under the `genui_*` and
// `pipeline_stage_*` discriminator tags.
//
// This module only assembles the wire payloads. The actual broadcast to a
// run's SSE stream goes through `apps/daemon/src/runs.ts`'s `emit(run,
// event, data)` helper, kept decoupled from this file so tests can swap
// the sink for an in-memory recorder.
import type {
GenUISurfaceEvent,
PluginPipelineStageEvent,
} from '@open-design/contracts';
import type { SurfaceRow } from './store.js';
export type GenUIEventSink = (event: GenUISurfaceEvent | PluginPipelineStageEvent) => void;
export function buildSurfaceRequestEvent(args: {
surfaceRow: SurfaceRow;
runId: string;
payload?: unknown;
}): GenUISurfaceEvent {
return {
kind: 'genui_surface_request',
surfaceId: args.surfaceRow.surfaceId,
runId: args.runId,
payload: args.payload ?? null,
requestedAt: args.surfaceRow.requestedAt,
};
}
export function buildSurfaceResponseEvent(args: {
surfaceRow: SurfaceRow;
runId: string;
respondedBy: SurfaceRow['respondedBy'];
}): GenUISurfaceEvent {
return {
kind: 'genui_surface_response',
surfaceId: args.surfaceRow.surfaceId,
runId: args.runId,
value: args.surfaceRow.value,
respondedAt: args.surfaceRow.respondedAt ?? Date.now(),
// Default to 'agent' so the discriminated union remains exhaustive
// even if a synthetic event is built before the response writer set
// `respondedBy`. Real responses always carry an explicit value.
respondedBy: args.respondedBy ?? 'agent',
};
}
export function buildSurfaceTimeoutEvent(args: {
surfaceRow: SurfaceRow;
runId: string;
resolution: 'abort' | 'default' | 'skip';
}): GenUISurfaceEvent {
return {
kind: 'genui_surface_timeout',
surfaceId: args.surfaceRow.surfaceId,
runId: args.runId,
resolution: args.resolution,
};
}
export function buildStateSyncedEvent(args: {
surfaceRow: SurfaceRow;
runId: string;
}): GenUISurfaceEvent {
return {
kind: 'genui_state_synced',
surfaceId: args.surfaceRow.surfaceId,
runId: args.runId,
persistTier: args.surfaceRow.persist,
};
}
export function buildPipelineStageStartedEvent(args: {
runId: string;
snapshotId: string;
stageId: string;
iteration: number;
}): PluginPipelineStageEvent {
return {
kind: 'pipeline_stage_started',
runId: args.runId,
snapshotId: args.snapshotId,
stageId: args.stageId,
iteration: args.iteration,
startedAt: Date.now(),
};
}
export function buildPipelineStageCompletedEvent(args: {
runId: string;
snapshotId: string;
stageId: string;
iteration: number;
converged?: boolean | undefined;
diffSummary?: string | undefined;
}): PluginPipelineStageEvent {
const evt: PluginPipelineStageEvent = {
kind: 'pipeline_stage_completed',
runId: args.runId,
snapshotId: args.snapshotId,
stageId: args.stageId,
iteration: args.iteration,
completedAt: Date.now(),
};
if (args.converged !== undefined) evt.converged = args.converged;
if (args.diffSummary !== undefined) evt.diffSummary = args.diffSummary;
return evt;
}

View file

@ -0,0 +1,31 @@
// Barrel for the GenUI module — see ./registry.ts for the high-level
// orchestration entry points and ./store.ts for the SQLite writer. Tests
// import from the barrel; production code may import directly when only
// the store or events helpers are needed.
export * from './events.js';
export * from './registry.js';
// Both registry and store export `respondSurface`; the registry version
// is the public-facing one (it emits the response event), while the
// store version is the SQLite writer used internally. Callers should
// reach the store version via the explicit `genuiStore` namespace.
export {
findPendingByRunAndSurfaceId,
getSurface,
listSurfacesForProject,
listSurfacesForRun,
markTimeout,
prefillSurface,
requestSurface,
revokeSurface,
} from './store.js';
export {
respondSurface as respondSurfaceRow,
} from './store.js';
export type {
SurfaceKind,
SurfaceRespondedBy,
SurfaceRow,
SurfaceStatus,
SurfaceTier,
} from './store.js';

View file

@ -0,0 +1,180 @@
// GenUI surface registry — high-level orchestration over store + events
// (spec §10.3). Houses:
//
// - `schemaDigest()` — stable hex of a JSON Schema (drives F8 cache invalidation)
// - `requestOrReuseSurface()` — F8 cache lookup → either reuse + emit a
// `genui_surface_response { respondedBy: 'cache' }` (no broadcast of a
// new request) or insert a `pending` row + emit a request event.
// - `respondSurface()` — write user / agent / auto answer, emit response
// + state-synced events.
// - `revokeSurface()` — flip cross-conversation rows to `invalidated`.
//
// All side effects are concentrated here; the underlying SQLite writes go
// through `store.ts`, the SSE / ND-JSON event emission goes through the
// caller-provided `GenUIEventSink` from `events.ts`. Tests can swap either.
import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
import type { GenUISurfaceSpec } from '@open-design/contracts';
import {
buildStateSyncedEvent,
buildSurfaceRequestEvent,
buildSurfaceResponseEvent,
type GenUIEventSink,
} from './events.js';
import {
lookupResolved,
prefillSurface,
requestSurface,
respondSurface as respondSurfaceRow,
revokeSurface as revokeSurfaceRow,
type RespondSurfaceInput,
type SurfaceKind,
type SurfaceRespondedBy,
type SurfaceRow,
type SurfaceTier,
} from './store.js';
type SqliteDb = Database.Database;
// Stable digest of a JSON-Schema-shaped object. Used by `genui_surfaces`
// rows so a schema upgrade auto-invalidates cached answers (spec §10.3.3).
// Canonical key order ensures parsing twice → same digest.
export function schemaDigest(schema: unknown): string {
if (schema === undefined || schema === null) return '';
const canonical = canonicalize(schema);
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
}
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
out[key] = canonicalize((value as Record<string, unknown>)[key]);
}
return out;
}
return value;
}
export interface RequestOrReuseInput {
projectId: string;
conversationId?: string | null | undefined;
runId: string;
pluginSnapshotId: string;
surface: GenUISurfaceSpec;
payload?: unknown;
emit?: GenUIEventSink;
}
export interface RequestOrReuseResult {
reused: boolean;
row: SurfaceRow;
}
// F8: try cache before broadcasting. On hit at the right tier with a
// matching schema digest and unexpired row, emit a response event with
// `respondedBy: 'cache'` and return the cached row. On miss, insert a
// pending row and emit a request event.
export function requestOrReuseSurface(
db: SqliteDb,
input: RequestOrReuseInput,
): RequestOrReuseResult {
const surface = input.surface;
const persist: SurfaceTier = surface.persist;
const digest = surface.schema ? schemaDigest(surface.schema) : null;
if (persist !== 'run') {
const cached = lookupResolved(db, {
projectId: input.projectId,
conversationId: input.conversationId,
surfaceId: surface.id,
persist,
schemaDigest: digest,
});
if (cached) {
input.emit?.(
buildSurfaceResponseEvent({
surfaceRow: cached,
runId: input.runId,
respondedBy: 'cache',
}),
);
return { reused: true, row: cached };
}
}
const row = requestSurface(db, {
projectId: input.projectId,
conversationId: input.conversationId,
runId: input.runId,
pluginSnapshotId: input.pluginSnapshotId,
surfaceId: surface.id,
kind: surface.kind,
persist,
schemaDigest: digest,
});
input.emit?.(
buildSurfaceRequestEvent({
surfaceRow: row,
runId: input.runId,
payload: input.payload ?? surface,
}),
);
return { reused: false, row };
}
export interface RespondInput extends RespondSurfaceInput {
runId: string;
emit?: GenUIEventSink;
}
export function respondSurface(db: SqliteDb, input: RespondInput): SurfaceRow {
const row = respondSurfaceRow(db, input);
input.emit?.(
buildSurfaceResponseEvent({
surfaceRow: row,
runId: input.runId,
respondedBy: input.respondedBy as SurfaceRespondedBy,
}),
);
if (row.persist !== 'run') {
input.emit?.(
buildStateSyncedEvent({ surfaceRow: row, runId: input.runId }),
);
}
return row;
}
export interface RevokeInput {
projectId: string;
surfaceId: string;
}
export function revokeProjectSurface(db: SqliteDb, input: RevokeInput): number {
return revokeSurfaceRow(db, input);
}
export interface PrefillInput {
projectId: string;
pluginSnapshotId: string;
surfaceId: string;
kind: SurfaceKind;
persist: SurfaceTier;
value: unknown;
schema?: unknown;
expiresAt?: number | null;
}
export function prefillProjectSurface(db: SqliteDb, input: PrefillInput): SurfaceRow {
const digest = input.schema !== undefined ? schemaDigest(input.schema) : null;
return prefillSurface(db, {
projectId: input.projectId,
pluginSnapshotId: input.pluginSnapshotId,
surfaceId: input.surfaceId,
kind: input.kind,
persist: input.persist,
value: input.value,
schemaDigest: digest,
expiresAt: input.expiresAt ?? null,
});
}

View file

@ -0,0 +1,291 @@
// GenUI surface persisted state writer (spec §10.3.3 / §11.4).
//
// Sole module that mutates the `genui_surfaces` table. F8 cross-conversation
// cache is implemented here: callers ask `lookupResolved()` before issuing a
// `request()`; on a hit at the matching tier with a matching schema digest
// and unexpired row, the daemon short-circuits with `respondedBy: 'cache'`
// and never broadcasts. Callers must NOT issue `INSERT/UPDATE` against
// `genui_surfaces` from anywhere else — the F8 invariant relies on a single
// writer.
import type Database from 'better-sqlite3';
import type {
GenUISurfaceSpec,
} from '@open-design/contracts';
import { randomUUID } from 'node:crypto';
type SqliteDb = Database.Database;
export type SurfaceStatus = 'pending' | 'resolved' | 'timeout' | 'invalidated';
export type SurfaceTier = 'run' | 'conversation' | 'project';
export type SurfaceRespondedBy = 'user' | 'agent' | 'auto' | 'cache';
export type SurfaceKind = GenUISurfaceSpec['kind'];
export interface SurfaceRow {
id: string;
projectId: string;
conversationId: string | null;
runId: string | null;
pluginSnapshotId: string;
surfaceId: string;
kind: SurfaceKind;
persist: SurfaceTier;
schemaDigest: string | null;
value: unknown;
status: SurfaceStatus;
respondedBy: SurfaceRespondedBy | null;
requestedAt: number;
respondedAt: number | null;
expiresAt: number | null;
}
export interface RequestSurfaceInput {
projectId: string;
conversationId?: string | null | undefined;
runId?: string | null | undefined;
pluginSnapshotId: string;
surfaceId: string;
kind: SurfaceKind;
persist: SurfaceTier;
schemaDigest?: string | null | undefined;
expiresAt?: number | null | undefined;
}
export interface RespondSurfaceInput {
rowId: string;
value: unknown;
respondedBy: SurfaceRespondedBy;
expiresAt?: number | null | undefined;
}
export interface PrefillSurfaceInput {
projectId: string;
pluginSnapshotId: string;
surfaceId: string;
kind: SurfaceKind;
persist: SurfaceTier;
value: unknown;
schemaDigest?: string | null | undefined;
expiresAt?: number | null | undefined;
}
interface SurfaceDbRow {
id: string;
project_id: string;
conversation_id: string | null;
run_id: string | null;
plugin_snapshot_id: string;
surface_id: string;
kind: string;
persist: string;
schema_digest: string | null;
value_json: string | null;
status: string;
responded_by: string | null;
requested_at: number;
responded_at: number | null;
expires_at: number | null;
}
function rowFromDb(row: SurfaceDbRow): SurfaceRow {
return {
id: row.id,
projectId: row.project_id,
conversationId: row.conversation_id,
runId: row.run_id,
pluginSnapshotId: row.plugin_snapshot_id,
surfaceId: row.surface_id,
kind: row.kind as SurfaceKind,
persist: row.persist as SurfaceTier,
schemaDigest: row.schema_digest,
value: row.value_json ? JSON.parse(row.value_json) : null,
status: row.status as SurfaceStatus,
respondedBy: (row.responded_by as SurfaceRespondedBy | null) ?? null,
requestedAt: row.requested_at,
respondedAt: row.responded_at,
expiresAt: row.expires_at,
};
}
// Insert a freshly-requested surface row in `pending` status. Returns the
// stored row. Callers should always check `lookupResolved()` first to honor
// the F8 cross-conversation cache.
export function requestSurface(db: SqliteDb, input: RequestSurfaceInput): SurfaceRow {
const id = randomUUID();
const now = Date.now();
const conversationId = input.conversationId ?? null;
const runId = input.runId ?? null;
db.prepare(
`INSERT INTO genui_surfaces (
id, project_id, conversation_id, run_id, plugin_snapshot_id,
surface_id, kind, persist, schema_digest, value_json, status,
responded_by, requested_at, responded_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', NULL, ?, NULL, ?)`,
).run(
id,
input.projectId,
conversationId,
runId,
input.pluginSnapshotId,
input.surfaceId,
input.kind,
input.persist,
input.schemaDigest ?? null,
now,
input.expiresAt ?? null,
);
const row = db.prepare(`SELECT * FROM genui_surfaces WHERE id = ?`).get(id) as SurfaceDbRow;
return rowFromDb(row);
}
export function respondSurface(db: SqliteDb, input: RespondSurfaceInput): SurfaceRow {
const now = Date.now();
db.prepare(
`UPDATE genui_surfaces
SET value_json = ?,
status = 'resolved',
responded_by = ?,
responded_at = ?,
expires_at = COALESCE(?, expires_at)
WHERE id = ?`,
).run(
JSON.stringify(input.value ?? null),
input.respondedBy,
now,
input.expiresAt ?? null,
input.rowId,
);
const row = db.prepare(`SELECT * FROM genui_surfaces WHERE id = ?`).get(input.rowId) as SurfaceDbRow | undefined;
if (!row) throw new Error(`genui_surfaces row ${input.rowId} disappeared after respond`);
return rowFromDb(row);
}
// Pre-answer a surface (spec §10.3.4 `od ui prefill`). Writes a row in
// `resolved` state without a prior `pending` row; subsequent
// `lookupResolved()` will hit the cache and skip the broadcast.
export function prefillSurface(db: SqliteDb, input: PrefillSurfaceInput): SurfaceRow {
const id = randomUUID();
const now = Date.now();
db.prepare(
`INSERT INTO genui_surfaces (
id, project_id, conversation_id, run_id, plugin_snapshot_id,
surface_id, kind, persist, schema_digest, value_json, status,
responded_by, requested_at, responded_at, expires_at
) VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, 'resolved', 'auto', ?, ?, ?)`,
).run(
id,
input.projectId,
input.pluginSnapshotId,
input.surfaceId,
input.kind,
input.persist,
input.schemaDigest ?? null,
JSON.stringify(input.value ?? null),
now,
now,
input.expiresAt ?? null,
);
const row = db.prepare(`SELECT * FROM genui_surfaces WHERE id = ?`).get(id) as SurfaceDbRow;
return rowFromDb(row);
}
// F8: lookup an existing resolved surface answer at the right tier.
// Returns null when the cache misses; the caller should then `requestSurface()`.
export function lookupResolved(
db: SqliteDb,
args: {
projectId: string;
conversationId?: string | null | undefined;
surfaceId: string;
persist: SurfaceTier;
schemaDigest?: string | null | undefined;
now?: number;
},
): SurfaceRow | null {
const now = args.now ?? Date.now();
let row: SurfaceDbRow | undefined;
if (args.persist === 'project') {
row = db.prepare(
`SELECT * FROM genui_surfaces
WHERE project_id = ? AND surface_id = ? AND status = 'resolved'
ORDER BY responded_at DESC LIMIT 1`,
).get(args.projectId, args.surfaceId) as SurfaceDbRow | undefined;
} else if (args.persist === 'conversation') {
if (!args.conversationId) return null;
row = db.prepare(
`SELECT * FROM genui_surfaces
WHERE conversation_id = ? AND surface_id = ? AND status = 'resolved'
ORDER BY responded_at DESC LIMIT 1`,
).get(args.conversationId, args.surfaceId) as SurfaceDbRow | undefined;
} else {
// 'run' tier never crosses runs; cache lookup is meaningless.
return null;
}
if (!row) return null;
if (row.expires_at !== null && row.expires_at <= now) {
invalidateRow(db, row.id);
return null;
}
if (args.schemaDigest !== undefined && args.schemaDigest !== null) {
if (row.schema_digest !== null && row.schema_digest !== args.schemaDigest) {
invalidateRow(db, row.id);
return null;
}
}
return rowFromDb(row);
}
// Cross-conversation revoke (spec §10.3.3 user revoke). Flips the row to
// `invalidated`; subsequent lookups miss and the next run re-asks the user.
export function revokeSurface(
db: SqliteDb,
args: { projectId: string; surfaceId: string },
): number {
const info = db.prepare(
`UPDATE genui_surfaces
SET status = 'invalidated', responded_by = COALESCE(responded_by, 'user')
WHERE project_id = ? AND surface_id = ? AND status != 'invalidated'`,
).run(args.projectId, args.surfaceId);
return Number(info.changes ?? 0);
}
export function markTimeout(db: SqliteDb, rowId: string): SurfaceRow | null {
db.prepare(`UPDATE genui_surfaces SET status = 'timeout' WHERE id = ? AND status = 'pending'`).run(rowId);
const row = db.prepare(`SELECT * FROM genui_surfaces WHERE id = ?`).get(rowId) as SurfaceDbRow | undefined;
return row ? rowFromDb(row) : null;
}
export function listSurfacesForRun(db: SqliteDb, runId: string): SurfaceRow[] {
const rows = db.prepare(
`SELECT * FROM genui_surfaces WHERE run_id = ? ORDER BY requested_at ASC`,
).all(runId) as SurfaceDbRow[];
return rows.map(rowFromDb);
}
export function listSurfacesForProject(db: SqliteDb, projectId: string): SurfaceRow[] {
const rows = db.prepare(
`SELECT * FROM genui_surfaces WHERE project_id = ? ORDER BY requested_at DESC`,
).all(projectId) as SurfaceDbRow[];
return rows.map(rowFromDb);
}
export function getSurface(db: SqliteDb, rowId: string): SurfaceRow | null {
const row = db.prepare(`SELECT * FROM genui_surfaces WHERE id = ?`).get(rowId) as SurfaceDbRow | undefined;
return row ? rowFromDb(row) : null;
}
export function findPendingByRunAndSurfaceId(
db: SqliteDb,
args: { runId: string; surfaceId: string },
): SurfaceRow | null {
const row = db.prepare(
`SELECT * FROM genui_surfaces
WHERE run_id = ? AND surface_id = ? AND status = 'pending'
ORDER BY requested_at DESC LIMIT 1`,
).get(args.runId, args.surfaceId) as SurfaceDbRow | undefined;
return row ? rowFromDb(row) : null;
}
function invalidateRow(db: SqliteDb, rowId: string): void {
db.prepare(`UPDATE genui_surfaces SET status = 'invalidated' WHERE id = ?`).run(rowId);
}

View file

@ -3,7 +3,7 @@
// share the exact env/argv/buildHint shape; a divergence here is the
// difference between an MCP snippet that works and one that EPERMs out
// when pasted into Antigravity / Cursor / VS Code (issue #848), or
// silently misses a non-default sidecar namespace.
// silently misses the sidecar transport endpoint.
//
// Side effects (the fs.existsSync probes, process.execPath, the
// ELECTRON_RUN_AS_NODE env read, OD_DATA_DIR resolution, sidecar IPC
@ -24,7 +24,7 @@ export interface BuildMcpInstallPayloadInputs {
* spawned `od mcp` should discover the live URL via the IPC
* status socket instead of a baked --daemon-url. */
isSidecarMode: boolean;
/** Already-filtered sidecar env entries (namespace, IPC base) the
/** Already-filtered sidecar transport env entries the
* caller wants propagated into the snippet. The caller decides
* what's worth propagating; this builder just merges. */
sidecarEnv: Record<string, string>;

View file

@ -1,6 +1,6 @@
import type { Express } from 'express';
import fs from 'node:fs';
import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto';
import { SIDECAR_ENV } from '@open-design/sidecar-proto';
import { buildMcpInstallPayload } from './mcp-install-info.js';
import { MCP_TEMPLATES, buildAcpMcpServers, buildClaudeMcpJson, isManagedProjectCwd, readMcpConfig, writeMcpConfig } from './mcp-config.js';
import { beginAuth, exchangeCodeForToken, refreshAccessToken } from './mcp-oauth.js';
@ -42,27 +42,16 @@ export function registerMcpRoutes(app: Express, ctx: RegisterMcpRoutesDeps) {
// The daemon was bootstrapped as a sidecar (tools-dev, packaged) iff
// bootstrapSidecarRuntime stamped OD_SIDECAR_IPC_PATH into the env.
// In sidecar mode the snippet omits --daemon-url and the spawned
// `od mcp` discovers the live URL via the IPC status socket on
// `od mcp` discovers the live URL via the concrete IPC endpoint on
// every spawn, so the client config survives ephemeral-port
// restarts. We also propagate OD_SIDECAR_NAMESPACE (and IPC_BASE
// when overridden) so a non-default namespace daemon stays
// reachable - the MCP client does not inherit the daemon's env,
// so without this the spawned `od mcp` would probe the default
// namespace socket and miss. For direct `od` / `od --port X`
// launches there is no IPC socket; the helper bakes --daemon-url
// so custom ports keep working.
// restarts. For direct `od` / `od --port X` launches there is no
// IPC socket; the helper bakes --daemon-url so custom ports keep
// working.
const sidecarIpcPath = process.env[SIDECAR_ENV.IPC_PATH];
const isSidecarMode = sidecarIpcPath != null && sidecarIpcPath.length > 0;
const sidecarEnv: Record<string, string> = {};
if (isSidecarMode) {
const ns = process.env[SIDECAR_ENV.NAMESPACE];
if (ns != null && ns !== SIDECAR_DEFAULTS.namespace) {
sidecarEnv[SIDECAR_ENV.NAMESPACE] = ns;
}
const ipcBase = process.env[SIDECAR_ENV.IPC_BASE];
if (ipcBase != null && ipcBase.length > 0) {
sidecarEnv[SIDECAR_ENV.IPC_BASE] = ipcBase;
}
sidecarEnv[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath;
}
const payload = buildMcpInstallPayload({
cliPath,

View file

@ -0,0 +1,332 @@
// Plugin apply pipeline. Spec §11.5 / plan F4 invariants:
//
// - Pure: no SQLite writes, no FS mutation, no network. Side effects
// belong to the caller (snapshots.ts persists, server.ts wires the
// SSE response, project create stages assets).
// - Inputs are validated against `manifest.od.inputs`; missing required
// fields raise `MissingInput` which the CLI/HTTP layer surfaces as 422.
// - The output `ApplyResult` is the contract between apply and:
// (a) `POST /api/projects` (project metadata + assets to stage)
// (b) `runs.ts` (snapshotId → systemPrompt block)
// (c) the chip strip (resolvedContext.items)
//
// The function is intentionally synchronous; future async resolution
// (e.g. live MCP capability probing) belongs in a wrapper that calls this.
import {
manifestSourceDigest,
resolveAppliedPipeline,
resolveContext,
type RegistryView,
} from '@open-design/plugin-runtime';
import {
renderPluginBlock,
resolveLocalizedText,
type AppliedPluginSnapshot,
type ApplyResult,
type InstalledPluginRecord,
type McpServerSpec,
type PluginAssetRef,
type PluginConnectorBinding,
type PluginConnectorRef,
type PluginManifest,
type PluginProjectMetadataPatch,
type TrustTier,
} from '@open-design/contracts';
import { resolveCapabilitiesGranted, requiredCapabilities } from './trust.js';
import {
deriveAutoOAuthPrompts,
mergeAutoOAuthPrompts,
resolveConnectorBindings,
type ConnectorProbe,
} from './connector-gate.js';
import { deriveAutoAtomSurfaces } from './atoms/auto-surfaces.js';
export class MissingInputError extends Error {
readonly fields: string[];
constructor(fields: string[]) {
super(`Missing required plugin inputs: ${fields.join(', ')}`);
this.fields = fields;
this.name = 'MissingInputError';
}
}
// Apply result narrows the trust tier to 'trusted' | 'restricted'. The
// installed-plugin record can carry 'bundled' (per §5.3); we coerce to
// 'trusted' at apply time so the snapshot's permission contract is binary.
export type ApplyTrust = 'trusted' | 'restricted';
export interface ApplyInput {
plugin: InstalledPluginRecord;
inputs: Record<string, unknown>;
registry: RegistryView;
trust?: TrustTier | undefined;
// The active project's design system, if any. Plugins that declared
// `od.context.designSystem.primary: true` without a concrete ref get
// bound to this id at apply time.
activeProjectDesignSystem?: { id: string; title?: string } | undefined;
// UI locale used to resolve localized manifest strings. Snapshots store
// the resolved string so historical runs never change when translations do.
locale?: string | undefined;
// Sync probe over the connector catalog + status maps. When supplied,
// apply resolves `od.connectors.*` against the live catalog and
// auto-derives an `oauth-prompt` GenUI surface for any not-yet-connected
// required connector (spec §10.3.1). When omitted (legacy callers, unit
// tests), the connector bindings stay in `pending` status and no
// auto-prompt is derived.
connectorProbe?: ConnectorProbe | undefined;
}
export interface ApplyComputed {
result: ApplyResult;
// The manifestSourceDigest for the apply-time inputs. Distinct from the
// ApplyResult so callers can pass it to snapshots.createSnapshot without
// re-hashing.
manifestSourceDigest: string;
warnings: string[];
}
export function applyPlugin(input: ApplyInput): ApplyComputed {
const manifest = input.plugin.manifest;
const rawTrust: TrustTier = input.trust ?? input.plugin.trust;
const trust: ApplyTrust = rawTrust === 'restricted' ? 'restricted' : 'trusted';
const validated = validateInputs(manifest, input.inputs);
if (validated.missing.length > 0) {
throw new MissingInputError(validated.missing);
}
const resolved = resolveContext(manifest, {
registry: {
...input.registry,
activeProjectDesignSystem: input.activeProjectDesignSystem,
},
warnOnMissing: true,
});
const digest = manifestSourceDigest({
manifest,
inputs: validated.coerced,
resolvedContextRefs: resolved.digestRefs,
});
const assets = buildAssetRefs(manifest);
const mcpServers = manifest.od?.context?.mcp?.slice() ?? [];
const { resolved: connectorsResolved, required: connectorsRequired } =
resolveConnectorBindings(manifest, input.connectorProbe);
const required = requiredCapabilities(manifest);
const granted = resolveCapabilitiesGranted({ manifest, trust });
const taskKind = (manifest.od?.taskKind ?? 'new-generation') as AppliedPluginSnapshot['taskKind'];
// Spec §23.3.3: when the plugin omits `od.pipeline`, fall back to
// the bundled scenario whose taskKind matches. The registry view
// carries the lookup; daemon callers populate it from the
// `installed_plugins` table filtered to source_kind='bundled' AND
// od.kind='scenario'. Tests + non-daemon callers can pass an empty
// list, in which case the pipeline stays undefined.
const pipelineResolution = resolveAppliedPipeline({
manifest,
scenarios: input.registry.scenarios,
});
const appliedPipeline = pipelineResolution.pipeline;
const declaredSurfaces = manifest.od?.genui?.surfaces ?? [];
const autoOAuth = input.connectorProbe
? deriveAutoOAuthPrompts(connectorsResolved)
: [];
// Spec §10.3.1 / §21.5: auto-derive surfaces for first-party atom
// stages (diff-review → choice surface). Plugin-author surfaces
// with the same id win; the merge helper handles the dedupe.
// We use the EFFECTIVE pipeline (appliedPipeline) so a plugin that
// inherits the bundled scenario's diff-review stage still gets
// the auto-surface.
const autoAtom = deriveAutoAtomSurfaces({ pipeline: appliedPipeline });
const genuiSurfaces = mergeAutoOAuthPrompts(
mergeAutoOAuthPrompts(declaredSurfaces, autoOAuth),
autoAtom,
);
const projectMetadata: PluginProjectMetadataPatch = {
name: manifest.title ?? manifest.name,
taskKind,
};
const skillRef = pickFirstSkillId(manifest);
if (skillRef) projectMetadata.skillId = skillRef;
const dsId = pickDesignSystemId(manifest, input.activeProjectDesignSystem);
if (dsId) projectMetadata.designSystemId = dsId;
if (Array.isArray(manifest.od?.context?.craft) && manifest.od!.context!.craft!.length > 0) {
projectMetadata.craftRequires = manifest.od!.context!.craft!.slice();
}
const queryText = resolveLocalizedText(manifest.od?.useCase?.query, input.locale);
const appliedAt = Date.now();
const snapshot: AppliedPluginSnapshot = {
snapshotId: '',
pluginId: input.plugin.id,
pluginSpecVersion: manifest.specVersion,
pluginVersion: input.plugin.version,
manifestSourceDigest: digest,
sourceMarketplaceId: input.plugin.sourceMarketplaceId,
sourceMarketplaceEntryName: input.plugin.sourceMarketplaceEntryName,
sourceMarketplaceEntryVersion: input.plugin.sourceMarketplaceEntryVersion,
marketplaceTrust: input.plugin.marketplaceTrust,
resolvedSource: input.plugin.resolvedSource,
resolvedRef: input.plugin.resolvedRef,
archiveIntegrity: input.plugin.archiveIntegrity,
pinnedRef: input.plugin.pinnedRef,
inputs: validated.coerced,
resolvedContext: resolved.context,
capabilitiesGranted: granted,
capabilitiesRequired: required,
assetsStaged: assets,
taskKind,
appliedAt,
connectorsRequired,
connectorsResolved,
mcpServers,
pipeline: appliedPipeline,
genuiSurfaces,
pluginTitle: manifest.title ?? manifest.name,
pluginDescription: manifest.description,
query: queryText || undefined,
status: 'fresh',
};
const result: ApplyResult = {
query: queryText,
contextItems: resolved.context.items,
inputs: manifest.od?.inputs ?? [],
assets,
mcpServers,
pipeline: appliedPipeline,
genuiSurfaces,
projectMetadata,
trust,
capabilitiesGranted: granted,
capabilitiesRequired: required,
appliedPlugin: snapshot,
};
return { result, manifestSourceDigest: digest, warnings: resolved.warnings };
}
interface ValidationResult {
coerced: Record<string, string | number | boolean>;
missing: string[];
}
function validateInputs(manifest: PluginManifest, raw: Record<string, unknown>): ValidationResult {
const fields = manifest.od?.inputs ?? [];
const coerced: Record<string, string | number | boolean> = {};
const missing: string[] = [];
for (const field of fields) {
const name = field.name;
if (!name) continue;
const provided = raw[name];
if (provided === undefined || provided === null || provided === '') {
const fallback = field.default;
if (fallback !== undefined && fallback !== null && fallback !== '') {
coerced[name] = coerceScalar(fallback as unknown);
} else if (field.required === true) {
missing.push(name);
}
continue;
}
coerced[name] = coerceScalar(provided);
}
// Forward-compat: pass through any extra keys the plugin author may have
// defined elsewhere (e.g. via `od.useCase` later). This keeps inputs lossy
// but predictable; the digest captures whatever survives coercion.
for (const [key, value] of Object.entries(raw)) {
if (key in coerced) continue;
if (value === undefined || value === null) continue;
coerced[key] = coerceScalar(value);
}
return { coerced, missing };
}
function coerceScalar(value: unknown): string | number | boolean {
if (typeof value === 'string') return value;
if (typeof value === 'number') return value;
if (typeof value === 'boolean') return value;
if (Array.isArray(value)) return value.join(', ');
return JSON.stringify(value);
}
function buildAssetRefs(manifest: PluginManifest): PluginAssetRef[] {
const out: PluginAssetRef[] = [];
for (const raw of manifest.od?.context?.assets ?? []) {
if (typeof raw !== 'string' || raw.length === 0) continue;
const path = raw;
out.push({ path, src: path, stageAt: 'run-start' });
}
return out;
}
// Pick a global skill id from od.context.skills[]. Two ref shapes are
// accepted:
//
// - `{ ref: 'skill-id' }` — registry id; returned as-is.
// - `{ path: 'subdir/SKILL.md' }` — plugin-local file; returned as
// undefined so the project record never stores a non-existent skill
// id like 'SKILL.md'. Plugin-local SKILL.md bodies are sourced
// directly by the daemon at prompt-compose time from the installed
// plugin's fsPath (see server.ts) — they do NOT roam into the
// global skills registry.
function pickFirstSkillId(manifest: PluginManifest): string | undefined {
for (const ref of manifest.od?.context?.skills ?? []) {
if (typeof ref?.ref === 'string' && ref.ref.trim().length > 0) {
return ref.ref.trim();
}
const rawPath = typeof ref?.path === 'string' ? ref.path.trim() : '';
if (!rawPath) continue;
if (isPluginLocalPath(rawPath)) continue;
return rawPath;
}
return undefined;
}
function isPluginLocalPath(value: string): boolean {
return (
value.startsWith('./') ||
value.startsWith('../') ||
value.includes('/')
);
}
// Return the first plugin-local skill ref path (relative to plugin
// fsPath), if any. Used by the daemon prompt composer to read a
// plugin's SKILL.md body without re-walking the manifest. Mirrors the
// detection inside `pickFirstSkillId` so the two stay in lockstep.
export function pickFirstLocalSkillPath(manifest: PluginManifest): string | undefined {
for (const ref of manifest.od?.context?.skills ?? []) {
if (typeof ref?.ref === 'string' && ref.ref.trim().length > 0) continue;
const rawPath = typeof ref?.path === 'string' ? ref.path.trim() : '';
if (!rawPath) continue;
if (!isPluginLocalPath(rawPath)) continue;
return rawPath;
}
return undefined;
}
function pickDesignSystemId(
manifest: PluginManifest,
active?: { id: string; title?: string },
): string | undefined {
const ds = manifest.od?.context?.designSystem;
if (ds && typeof ds.ref === 'string' && ds.ref.trim()) return ds.ref.trim();
if (ds && active?.id) return active.id;
return undefined;
}
// Plugin prompt block renderer. Lives in
// `packages/contracts/src/prompts/plugin-block.ts` so the daemon and the
// contracts-side composer share one definition (spec §11.8 PB1).
// Re-exported here for back-compat with daemon-internal callers.
export const pluginPromptBlock = renderPluginBlock;
export type { McpServerSpec };

View file

@ -0,0 +1,80 @@
// Phase 4 / spec §23.3.2 patch 2 — atom SKILL.md body loader.
//
// `composeSystemPrompt()` today inlines every atom's prompt fragment as
// a TypeScript string constant in `apps/daemon/src/prompts/system.ts`.
// Spec §23 wants those constants migrated into the matching
// `plugins/_official/atoms/<atom>/SKILL.md` so the prompt becomes
// data-driven (the same registry path third-party plugins use).
//
// This module is the substrate slice. It owns the bundled-atom →
// SKILL.md resolution; the actual `composeSystemPrompt` rewiring is the
// next PR (spec §23.4 sketch). Today the helper is consumed by the
// pipeline runner's stage-entry block via the renderer in
// `packages/contracts/src/prompts/atom-block.ts`.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import type Database from 'better-sqlite3';
import { getInstalledPlugin } from './registry.js';
type SqliteDb = Database.Database;
export interface AtomBodyEntry {
atomId: string;
pluginId: string;
// Trimmed SKILL.md body (frontmatter stripped). Empty when the
// bundled plugin's SKILL.md is missing or unreadable; the caller
// should drop empty entries from the prompt block.
body: string;
}
// Load SKILL.md bodies for every requested atom id. Looks each id up
// in `installed_plugins` (source_kind='bundled' wins; falls back to
// any installed plugin with the same id), then reads `fs_path/SKILL.md`.
// Front-matter (`---\n…\n---`) is stripped so the body slot is ready to
// concatenate into the system prompt.
export async function loadAtomBodies(
db: SqliteDb,
atomIds: ReadonlyArray<string>,
): Promise<AtomBodyEntry[]> {
const out: AtomBodyEntry[] = [];
for (const id of atomIds) {
const slug = id.toLowerCase();
const plugin = preferBundledPlugin(db, slug);
if (!plugin) continue;
let raw: string;
try {
raw = await fsp.readFile(path.join(plugin.fsPath, 'SKILL.md'), 'utf8');
} catch {
continue;
}
const body = stripFrontmatter(raw).trim();
if (!body) continue;
out.push({ atomId: slug, pluginId: plugin.id, body });
}
return out;
}
function preferBundledPlugin(db: SqliteDb, id: string) {
// Look first for a bundled record with the requested id.
const bundled = db
.prepare(`SELECT id FROM installed_plugins WHERE id = ? AND source_kind = 'bundled' LIMIT 1`)
.get(id) as { id?: string } | undefined;
if (bundled?.id) {
return getInstalledPlugin(db, bundled.id);
}
return getInstalledPlugin(db, id);
}
// Strip the leading YAML/TOML-style frontmatter block. Any plugin
// SKILL.md that follows the spec §11.3 / skills-protocol.md shape
// starts with `---\n…\n---\n`. We only need the body; the frontmatter
// fields are already projected into `installed_plugins.manifest_json`.
function stripFrontmatter(raw: string): string {
if (!raw.startsWith('---')) return raw;
const closeIdx = raw.indexOf('\n---', 3);
if (closeIdx === -1) return raw;
// Skip past the closing `\n---` and any trailing newline.
const after = raw.slice(closeIdx + 4);
return after.replace(/^\r?\n/, '');
}

View file

@ -0,0 +1,55 @@
// First-party atom catalog. Phase 1 ships a static list mirroring spec
// §10's "implemented today" column. Planned atoms are surfaced with
// `status: 'planned'` so `od plugin doctor` can warn rather than reject
// when a plugin references a not-yet-implemented atom.
export type AtomStatus = 'implemented' | 'planned';
export interface AtomCatalogEntry {
id: string;
label: string;
description: string;
status: AtomStatus;
taskKinds: ReadonlyArray<'new-generation' | 'code-migration' | 'figma-migration' | 'tune-collab'>;
}
export const FIRST_PARTY_ATOMS: ReadonlyArray<AtomCatalogEntry> = [
{ id: 'discovery-question-form', label: 'Discovery question form', description: 'Turn-1 question form for ambiguous briefs.', status: 'implemented', taskKinds: ['new-generation', 'tune-collab'] },
{ id: 'direction-picker', label: 'Direction picker', description: '3-5 direction picker before final.', status: 'implemented', taskKinds: ['new-generation', 'tune-collab'] },
{ id: 'todo-write', label: 'Todo write', description: 'TodoWrite-driven plan.', status: 'implemented', taskKinds: ['new-generation', 'code-migration', 'figma-migration', 'tune-collab'] },
{ id: 'file-read', label: 'File read', description: 'Read project files.', status: 'implemented', taskKinds: ['new-generation', 'code-migration', 'figma-migration', 'tune-collab'] },
{ id: 'file-write', label: 'File write', description: 'Write project files.', status: 'implemented', taskKinds: ['new-generation', 'code-migration', 'figma-migration', 'tune-collab'] },
{ id: 'file-edit', label: 'File edit', description: 'Edit project files.', status: 'implemented', taskKinds: ['new-generation', 'code-migration', 'figma-migration', 'tune-collab'] },
{ id: 'research-search', label: 'Research search', description: 'Tavily-backed shallow research.', status: 'implemented', taskKinds: ['new-generation'] },
{ id: 'media-image', label: 'Media image', description: 'Image generation through media providers.', status: 'implemented', taskKinds: ['new-generation', 'tune-collab'] },
{ id: 'media-video', label: 'Media video', description: 'Video generation through media providers.', status: 'implemented', taskKinds: ['new-generation', 'tune-collab'] },
{ id: 'media-audio', label: 'Media audio', description: 'Audio generation through media providers.', status: 'implemented', taskKinds: ['new-generation', 'tune-collab'] },
{ id: 'live-artifact', label: 'Live artifact', description: 'Create/refresh live artifacts.', status: 'implemented', taskKinds: ['new-generation', 'tune-collab'] },
{ id: 'connector', label: 'Connector', description: 'Composio connector tool calls.', status: 'implemented', taskKinds: ['new-generation', 'tune-collab'] },
{ id: 'critique-theater', label: 'Critique theater', description: '5-dim panel critique; devloop signal.', status: 'implemented', taskKinds: ['new-generation', 'code-migration', 'figma-migration', 'tune-collab'] },
// Phase 6/7/8 atoms — promoted from 'planned' to 'implemented'
// by the §3.N1-N4 / §3.O2-O5 / §3.P1-P2 / §3.Q2 / §3.S1 slices.
{ id: 'code-import', label: 'Code import', description: 'Walk an existing repo into <cwd>/code/index.json.', status: 'implemented', taskKinds: ['code-migration'] },
{ id: 'design-extract', label: 'Design extract', description: 'Extract design tokens into <cwd>/code/tokens.json.', status: 'implemented', taskKinds: ['code-migration', 'figma-migration'] },
{ id: 'figma-extract', label: 'Figma extract', description: 'Pull Figma file tree + assets via REST.', status: 'implemented', taskKinds: ['figma-migration'] },
{ id: 'token-map', label: 'Token map', description: 'Crosswalk source token bag onto active design system.', status: 'implemented', taskKinds: ['code-migration', 'figma-migration'] },
{ id: 'rewrite-plan', label: 'Rewrite plan', description: 'Heuristic ownership classifier + per-leaf step list.', status: 'implemented', taskKinds: ['code-migration', 'tune-collab'] },
{ id: 'patch-edit', label: 'Patch edit', description: 'Atomic unified-diff applier with shell-tier safety gate.', status: 'implemented', taskKinds: ['code-migration', 'tune-collab'] },
{ id: 'build-test', label: 'Build / test', description: 'Shell-out to typecheck + tests; emits build/tests.passing signals.', status: 'implemented', taskKinds: ['code-migration'] },
{ id: 'diff-review', label: 'Diff review', description: 'Render rewrite as review/{diff.patch,summary.md,decision.json}.', status: 'implemented', taskKinds: ['code-migration', 'tune-collab'] },
{ id: 'handoff', label: 'Handoff', description: 'Update ArtifactManifest provenance + handoffKind ladder.', status: 'implemented', taskKinds: ['code-migration', 'tune-collab'] },
];
const ATOMS_BY_ID = new Map<string, AtomCatalogEntry>(FIRST_PARTY_ATOMS.map((a) => [a.id, a]));
export function findAtom(id: string): AtomCatalogEntry | undefined {
return ATOMS_BY_ID.get(id);
}
export function isKnownAtom(id: string): boolean {
return ATOMS_BY_ID.has(id);
}
export function isImplementedAtom(id: string): boolean {
return ATOMS_BY_ID.get(id)?.status === 'implemented';
}

View file

@ -0,0 +1,77 @@
// Phase 8 entry slice / spec §10.3.1 / §21.5 — auto-derived GenUI surfaces
// for first-party atom stages.
//
// Mirrors the connector-gate's auto oauth-prompt pattern: when the
// pipeline contains a `diff-review` stage, the daemon synthesises a
// `choice` GenUI surface (`__auto_diff_review_<stageId>`,
// persist='run') so the user can accept / reject / partial without
// the plugin author having to declare the surface by hand.
// Plugin-author-declared surfaces with the same id win — this helper
// returns the implicit list and `mergeAutoOAuthPrompts` (re-used) does
// the dedupe.
//
// Other atom stages that auto-derive surfaces in the future (e.g.
// `direction-picker` could auto-derive a `choice`) plug in here.
import type {
GenUISurfaceSpec,
PluginPipeline,
} from '@open-design/contracts';
export interface AutoAtomSurfaceContext {
pipeline?: PluginPipeline | undefined;
}
export function deriveAutoAtomSurfaces(
ctx: AutoAtomSurfaceContext,
): GenUISurfaceSpec[] {
const out: GenUISurfaceSpec[] = [];
if (!ctx.pipeline) return out;
for (const stage of ctx.pipeline.stages) {
const atoms = stage.atoms ?? [];
if (atoms.includes('diff-review')) {
out.push(buildDiffReviewSurface(stage.id));
}
}
return out;
}
function buildDiffReviewSurface(stageId: string): GenUISurfaceSpec {
return {
id: `__auto_diff_review_${stageId}`,
kind: 'choice',
persist: 'run',
trigger: { stageId, atom: 'diff-review' },
prompt: 'Review the diff and choose how to proceed.',
schema: {
type: 'object',
title: 'Diff review',
description: 'Accept the patch, reject it, or pick which files to keep.',
properties: {
decision: {
type: 'string',
enum: ['accept', 'reject', 'partial'],
title: 'Decision',
},
accepted_files: {
type: 'array',
items: { type: 'string' },
title: 'Files to accept (only required when decision=partial)',
},
rejected_files: {
type: 'array',
items: { type: 'string' },
title: 'Files to reject (only required when decision=partial)',
},
reason: {
type: 'string',
title: 'Notes for the patch author',
},
},
required: ['decision'],
},
timeout: 24 * 60 * 60 * 1000, // 24h — diff review may sit overnight
onTimeout: 'abort',
capabilitiesRequired: [],
};
}

View file

@ -0,0 +1,278 @@
// Phase 7 entry slice / spec §10 / §22.4 — build-test atom runner.
//
// Spec-side atom contract lives in `plugins/_official/atoms/build-test/SKILL.md`.
// This module is the daemon-side shell-out implementation: given a
// project cwd + an optional command override, run typecheck + test
// commands, write `critique/build-test.json`, and return the
// signals the devloop's `until` evaluator reads
// (`build.passing`, `tests.passing`, plus the legacy `critique.score`).
//
// The runner is intentionally framework-agnostic — it prefers the
// declared override, falls back to scripts inside `package.json`,
// and otherwise emits a `tests: 'skipped'` outcome with an explicit
// reason so the agent doesn't claim success on a project it never
// actually tested.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { spawn, type SpawnOptions } from 'node:child_process';
import type { UntilSignals } from '../until.js';
export type BuildTestStatus = 'passing' | 'failing' | 'skipped';
export interface BuildTestCommandResult {
command: string;
exitCode: number;
durationMs: number;
status: BuildTestStatus;
// Truncated combined stdout + stderr. Bounded by `logBudgetBytes`.
log: string;
// When status='skipped'; explains why.
reason?: string;
}
export interface BuildTestReport {
build: BuildTestCommandResult;
tests: BuildTestCommandResult;
// Signals the pipeline runner forwards into the devloop `until` eval.
signals: UntilSignals;
// ISO timestamp of when the run completed.
endedAt: string;
}
export interface BuildTestRunOptions {
// Project cwd. The atom shells out inside this directory; the
// caller is responsible for staging the imported repo here.
cwd: string;
// Explicit overrides (highest priority). Either OR both can be
// null — the atom skips the matching half cleanly.
buildCommand?: string | null;
testCommand?: string | null;
// Per-command timeout (ms). Default 5 min.
timeoutMs?: number;
// Cap on how many bytes of combined stdout/stderr we keep per
// command. Default 1 MiB.
logBudgetBytes?: number;
// Pluggable child-process spawner so unit tests don't actually
// shell out. Production passes node:child_process.spawn.
spawnFn?: (cmd: string, args: string[], opts: SpawnOptions) => ReturnType<typeof spawn>;
}
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
const DEFAULT_LOG_BUDGET = 1 * 1024 * 1024;
export async function runBuildTest(opts: BuildTestRunOptions): Promise<BuildTestReport> {
const cwd = path.resolve(opts.cwd);
const inferred = await inferCommands(cwd);
const buildCmd = opts.buildCommand !== undefined ? opts.buildCommand : inferred.build;
const testCmd = opts.testCommand !== undefined ? opts.testCommand : inferred.test;
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const logBudget = opts.logBudgetBytes ?? DEFAULT_LOG_BUDGET;
const build = await runCommandOrSkip({
label: 'build',
command: buildCmd,
cwd,
timeoutMs,
logBudget,
spawnFn: opts.spawnFn,
skipReason: 'no build / typecheck command resolved (set --build-command or add `typecheck` to package.json scripts)',
});
const tests = await runCommandOrSkip({
label: 'tests',
command: testCmd,
cwd,
timeoutMs,
logBudget,
spawnFn: opts.spawnFn,
skipReason: 'no test command resolved (set --test-command or add `test` to package.json scripts)',
});
const signals = computeSignals({ build, tests });
return { build, tests, signals, endedAt: new Date().toISOString() };
}
// Persist the report under the project cwd in the canonical layout the
// SKILL.md fragment locks. Returns the file paths so the caller can
// echo them on the run's SSE stream.
export async function writeBuildTestReport(args: {
cwd: string;
report: BuildTestReport;
}): Promise<{ jsonPath: string; logPath: string }> {
const dir = path.join(args.cwd, 'critique');
await fsp.mkdir(dir, { recursive: true });
const jsonPath = path.join(dir, 'build-test.json');
const logPath = path.join(dir, 'build-test.log');
const json = {
build: statusOnly(args.report.build),
tests: statusOnly(args.report.tests),
durationMs: args.report.build.durationMs + args.report.tests.durationMs,
commandsRun: [args.report.build.command, args.report.tests.command].filter(Boolean),
failures: [args.report.build, args.report.tests]
.filter((c) => c.status === 'failing')
.map((c) => ({ command: c.command, exitCode: c.exitCode })),
endedAt: args.report.endedAt,
signals: args.report.signals,
};
await fsp.writeFile(jsonPath, JSON.stringify(json, null, 2) + '\n', 'utf8');
const logBody = [
`# build (${args.report.build.status}) — ${args.report.build.command || '<skipped>'}`,
args.report.build.log,
'',
`# tests (${args.report.tests.status}) — ${args.report.tests.command || '<skipped>'}`,
args.report.tests.log,
].join('\n');
await fsp.writeFile(logPath, logBody, 'utf8');
return { jsonPath, logPath };
}
interface InferredCommands {
build: string | null;
test: string | null;
}
async function inferCommands(cwd: string): Promise<InferredCommands> {
try {
const raw = await fsp.readFile(path.join(cwd, 'package.json'), 'utf8');
const pkg = JSON.parse(raw) as { scripts?: Record<string, string> };
const scripts = pkg.scripts ?? {};
const pmRunner = await detectPackageManager(cwd);
const buildScript = scripts.typecheck ?? scripts.build ?? null;
const testScript = scripts.test ?? null;
return {
build: buildScript ? `${pmRunner} run ${pickScriptName(scripts, ['typecheck', 'build'])}` : null,
test: testScript ? `${pmRunner} run test` : null,
};
} catch {
return { build: null, test: null };
}
}
function pickScriptName(scripts: Record<string, string>, preferred: string[]): string {
for (const name of preferred) if (scripts[name]) return name;
return preferred[0]!;
}
async function detectPackageManager(cwd: string): Promise<'pnpm' | 'npm' | 'yarn'> {
if (await pathExists(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm';
if (await pathExists(path.join(cwd, 'yarn.lock'))) return 'yarn';
return 'npm';
}
async function pathExists(p: string): Promise<boolean> {
try { await fsp.access(p); return true; } catch { return false; }
}
interface RunCommandArgs {
label: 'build' | 'tests';
command: string | null | undefined;
cwd: string;
timeoutMs: number;
logBudget: number;
spawnFn: BuildTestRunOptions['spawnFn'];
skipReason: string;
}
async function runCommandOrSkip(args: RunCommandArgs): Promise<BuildTestCommandResult> {
if (!args.command) {
return {
command: '',
exitCode: 0,
durationMs: 0,
status: 'skipped',
log: '',
reason: args.skipReason,
};
}
return runShell({
command: args.command,
cwd: args.cwd,
timeoutMs: args.timeoutMs,
logBudget: args.logBudget,
spawnFn: args.spawnFn,
});
}
interface RunShellArgs {
command: string;
cwd: string;
timeoutMs: number;
logBudget: number;
spawnFn?: BuildTestRunOptions['spawnFn'];
}
function runShell(args: RunShellArgs): Promise<BuildTestCommandResult> {
return new Promise((resolve) => {
const startedAt = Date.now();
const launcher = args.spawnFn ?? spawn;
const child = launcher('sh', ['-c', args.command], {
cwd: args.cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
let buffer = '';
let truncated = false;
const onChunk = (chunk: Buffer | string) => {
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
const remaining = args.logBudget - buffer.length;
if (remaining <= 0) { truncated = true; return; }
buffer += text.length > remaining ? text.slice(0, remaining) : text;
if (text.length > remaining) truncated = true;
};
child.stdout?.on('data', onChunk);
child.stderr?.on('data', onChunk);
const timer = setTimeout(() => {
try { child.kill('SIGTERM'); } catch { /* already dead */ }
}, args.timeoutMs);
timer.unref?.();
child.on('close', (code, signal) => {
clearTimeout(timer);
const exitCode = typeof code === 'number' ? code : (signal ? 124 : 1);
const status: BuildTestStatus = exitCode === 0 ? 'passing' : 'failing';
resolve({
command: args.command,
exitCode,
durationMs: Date.now() - startedAt,
status,
log: truncated ? buffer + '\n…[truncated]' : buffer,
});
});
child.on('error', (err) => {
clearTimeout(timer);
resolve({
command: args.command,
exitCode: 1,
durationMs: Date.now() - startedAt,
status: 'failing',
log: `[spawn-error] ${err.message ?? String(err)}`,
});
});
});
}
function statusOnly(c: BuildTestCommandResult) {
const out: Record<string, unknown> = {
command: c.command,
status: c.status,
exitCode: c.exitCode,
durationMs: c.durationMs,
};
if (c.reason) out.reason = c.reason;
return out;
}
function computeSignals({ build, tests }: { build: BuildTestCommandResult; tests: BuildTestCommandResult }): UntilSignals {
const buildPassing = build.status === 'passing' || build.status === 'skipped';
const testsPassing = tests.status === 'passing' || tests.status === 'skipped';
// Legacy critique.score axis: 5 when both pass, 3 when only build
// passes, 1 otherwise. spec §22.4 says the scoring is meant for
// pipelines that already read critique.score; new pipelines should
// read build.passing / tests.passing directly.
const score = buildPassing && testsPassing ? 5
: buildPassing ? 3
: 1;
return {
'build.passing': buildPassing,
'tests.passing': testsPassing,
'critique.score': score,
};
}

View file

@ -0,0 +1,79 @@
// Plan §3.D — built-in atom workers.
//
// Registered on first use into the worker registry. Every atom in
// FIRST_PARTY_ATOMS gets at least a permissive worker so the
// registry-driven pipeline runner stays at parity with the v1 stub
// for atoms whose real work happens entirely inside the agent CLI
// (file-write, todo-write, media-image, …) — the daemon has no
// independent ground truth to observe there and shipping a real
// watcher would force the agent into a fixed protocol we explicitly
// kept out of scope.
//
// One atom does have a daemon-observable signal today:
// `critique-theater`. The worker walks the run's devloop audit log
// (`run_devloop_iterations.critique_summary`) and surfaces the
// most recent numeric score it finds. Picking "latest" rather than
// "lowest" matches real critique-loop semantics: the agent revises
// based on prior critique, so each new score reflects the current
// quality bar, not the worst earlier attempt.
import { FIRST_PARTY_ATOMS } from '../atoms.js';
import {
registerAtomWorker,
type AtomOutcome,
type AtomWorkerContext,
} from './registry.js';
let installed = false;
export function registerBuiltInAtomWorkers(): void {
if (installed) return;
for (const atom of FIRST_PARTY_ATOMS) {
if (atom.id === 'critique-theater') {
registerAtomWorker({
id: atom.id,
describe: 'reads run_devloop_iterations.critique_summary for real critique scores',
run: critiqueTheaterWorker,
});
continue;
}
registerAtomWorker({
id: atom.id,
describe: 'permissive default (daemon has no independent ground truth for this atom)',
run: () => ({ signals: {} }),
});
}
installed = true;
}
export function resetBuiltInAtomWorkersForTests(): void {
installed = false;
}
function critiqueTheaterWorker(ctx: AtomWorkerContext): AtomOutcome {
type Row = { iteration: number; critique_summary: string | null };
const rows = ctx.db
.prepare(
'SELECT iteration, critique_summary FROM run_devloop_iterations WHERE run_id = ? AND stage_id = ? ORDER BY iteration DESC',
)
.all(ctx.runId, ctx.stage.id) as Row[];
for (const row of rows) {
const score = parseCritiqueScore(row.critique_summary);
if (score === null) continue;
return {
signals: { 'critique.score': score },
note: `latest critique score=${score} from iteration ${row.iteration}`,
};
}
return { signals: {} };
}
// Matches `score=4`, `score: 4.5`, `Critique score 4/5`, etc.
function parseCritiqueScore(summary: string | null): number | null {
if (!summary) return null;
const match = summary.match(/score\s*[:=]?\s*(\d+(?:\.\d+)?)/i);
if (!match) return null;
const parsed = Number(match[1]);
if (!Number.isFinite(parsed)) return null;
return parsed;
}

View file

@ -0,0 +1,276 @@
// Phase 7 entry slice / spec §10 / §21.3.2 — code-import atom runner.
//
// SKILL.md fragment lives at plugins/_official/atoms/code-import/. The
// runner walks an existing repository's tree and writes a normalised
// snapshot under `<projectCwd>/code/` so subsequent atoms
// (`design-extract`, `rewrite-plan`, `patch-edit`, `build-test`)
// don't have to re-walk on every turn.
//
// The walk respects:
// - a budget (`OD_CODE_IMPORT_BUDGET_MS`, default 60s) so monorepos
// don't burn an entire run on import;
// - the standard skip-list (node_modules, .git, .next, dist, build,
// out, .turbo, .pnpm-store) — recorded under
// `code/index.json.skipped[]` with a reason so the human can audit;
// - the §11.5.1 patch-safety contract via lightweight framework
// detection (next / vite / remix / astro / sveltekit / cra /
// custom).
import path from 'node:path';
import { promises as fsp } from 'node:fs';
export interface CodeImportFileEntry {
path: string;
size: number;
language: 'ts' | 'tsx' | 'js' | 'jsx' | 'css' | 'scss' | 'json' | 'html' | 'md' | 'other';
// Lightweight import edges (regex-extracted from `from '…'`). The
// pass is heuristic: we accept false positives (commented imports
// sneak through) and document the limitation in the SKILL.md
// fragment so the agent doesn't treat the list as authoritative.
imports?: string[];
}
export interface CodeImportSkipped {
path: string;
reason: 'directory-skiplist' | 'unsupported-extension' | 'budget-exceeded' | 'symlink' | 'large-file';
}
export interface CodeImportIndex {
files: CodeImportFileEntry[];
skipped: CodeImportSkipped[];
framework: 'next' | 'vite' | 'remix' | 'astro' | 'sveltekit' | 'cra' | 'custom' | 'unknown';
packageManager: 'pnpm' | 'npm' | 'yarn' | 'bun' | 'unknown';
packageJson?: { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
styleSystem: 'tailwind' | 'css' | 'styled-components' | 'emotion' | 'unknown';
routes?: { kind: 'next-app' | 'next-pages' | 'react-router' | 'vite-router' | 'sveltekit' | 'unknown' };
walkedAt: string;
walkBudgetMs: number;
}
export interface CodeImportRunOptions {
// Repo to walk. The atom never mutates this directory.
repoPath: string;
// Project cwd to write the snapshot under.
cwd: string;
// Walk budget (ms). Default 60s. A larger monorepo aborts gracefully
// and records every unwalked entry in `skipped[]` with reason
// 'budget-exceeded'.
budgetMs?: number;
// Per-file size cap (bytes). Files above this are listed but their
// imports[] stay empty. Default 1 MiB.
largeFileBytes?: number;
}
const DEFAULT_BUDGET_MS = 60_000;
const DEFAULT_LARGE_FILE = 1 * 1024 * 1024;
const SKIPLIST = new Set([
'node_modules',
'.git',
'.next',
'.svelte-kit',
'.nuxt',
'.astro',
'.turbo',
'.cache',
'.pnpm-store',
'.parcel-cache',
'dist',
'build',
'out',
'coverage',
'.vercel',
'.vscode',
]);
const LANG_EXT: Record<string, CodeImportFileEntry['language']> = {
'.ts': 'ts',
'.tsx': 'tsx',
'.js': 'js',
'.jsx': 'jsx',
'.cjs': 'js',
'.mjs': 'js',
'.css': 'css',
'.scss': 'scss',
'.sass': 'scss',
'.json': 'json',
'.html': 'html',
'.htm': 'html',
'.md': 'md',
'.mdx': 'md',
};
const IMPORT_RE = /^\s*import\s+(?:[^'"`]+\sfrom\s+)?['"]([^'"]+)['"]/gm;
export async function runCodeImport(opts: CodeImportRunOptions): Promise<CodeImportIndex> {
const repoPath = path.resolve(opts.repoPath);
const cwd = path.resolve(opts.cwd);
const budgetMs = opts.budgetMs ?? DEFAULT_BUDGET_MS;
const largeFileBytes = opts.largeFileBytes ?? DEFAULT_LARGE_FILE;
const startedAt = Date.now();
const stats = await fsp.stat(repoPath).catch(() => null);
if (!stats || !stats.isDirectory()) {
throw new Error(`code-import: repoPath ${repoPath} is not a directory`);
}
const pkg = await readPackageJson(repoPath);
const framework = detectFramework(repoPath, pkg);
const packageManager = await detectPackageManager(repoPath);
const styleSystem = detectStyleSystem(pkg);
const routes = await detectRoutes(repoPath, framework);
const files: CodeImportFileEntry[] = [];
const skipped: CodeImportSkipped[] = [];
const queue: string[] = [repoPath];
while (queue.length > 0) {
const dir = queue.pop()!;
if (Date.now() - startedAt > budgetMs) {
skipped.push({ path: path.relative(repoPath, dir) || '.', reason: 'budget-exceeded' });
// Drain remaining queue entries as budget-exceeded so the
// human can see what we missed.
for (const remaining of queue) {
skipped.push({ path: path.relative(repoPath, remaining) || '.', reason: 'budget-exceeded' });
}
break;
}
let entries;
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const abs = path.join(dir, entry.name);
const rel = path.relative(repoPath, abs);
if (entry.isSymbolicLink()) {
skipped.push({ path: rel, reason: 'symlink' });
continue;
}
if (entry.isDirectory()) {
if (SKIPLIST.has(entry.name)) {
skipped.push({ path: rel, reason: 'directory-skiplist' });
continue;
}
queue.push(abs);
continue;
}
if (!entry.isFile()) continue;
const lang = LANG_EXT[path.extname(entry.name).toLowerCase()];
if (!lang) {
skipped.push({ path: rel, reason: 'unsupported-extension' });
continue;
}
const stat = await fsp.stat(abs);
const fileEntry: CodeImportFileEntry = {
path: rel.split(path.sep).join('/'),
size: stat.size,
language: lang,
};
if (stat.size > largeFileBytes) {
skipped.push({ path: rel, reason: 'large-file' });
} else if (lang === 'ts' || lang === 'tsx' || lang === 'js' || lang === 'jsx') {
try {
const text = await fsp.readFile(abs, 'utf8');
const imports = extractImports(text);
if (imports.length > 0) fileEntry.imports = imports;
} catch {
// best-effort; skip imports[]
}
}
files.push(fileEntry);
}
}
const index: CodeImportIndex = {
files,
skipped,
framework,
packageManager,
styleSystem,
walkedAt: new Date().toISOString(),
walkBudgetMs: budgetMs,
};
if (pkg) index.packageJson = pkg;
if (routes) index.routes = routes;
// Persist under cwd.
const dir = path.join(cwd, 'code');
await fsp.mkdir(dir, { recursive: true });
await fsp.writeFile(path.join(dir, 'index.json'), JSON.stringify(index, null, 2) + '\n', 'utf8');
return index;
}
async function readPackageJson(repoPath: string): Promise<CodeImportIndex['packageJson'] | undefined> {
try {
const raw = await fsp.readFile(path.join(repoPath, 'package.json'), 'utf8');
const pkg = JSON.parse(raw) as CodeImportIndex['packageJson'];
return pkg ?? undefined;
} catch {
return undefined;
}
}
function detectFramework(_repoPath: string, pkg: CodeImportIndex['packageJson']): CodeImportIndex['framework'] {
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
if (deps['next']) return 'next';
if (deps['@sveltejs/kit']) return 'sveltekit';
if (deps['astro']) return 'astro';
if (deps['@remix-run/react'] || deps['@remix-run/node']) return 'remix';
if (deps['vite']) return 'vite';
if (deps['react-scripts']) return 'cra';
if (deps['react'] || deps['vue'] || deps['svelte']) return 'custom';
return 'unknown';
}
async function detectPackageManager(repoPath: string): Promise<CodeImportIndex['packageManager']> {
if (await pathExists(path.join(repoPath, 'pnpm-lock.yaml'))) return 'pnpm';
if (await pathExists(path.join(repoPath, 'yarn.lock'))) return 'yarn';
if (await pathExists(path.join(repoPath, 'bun.lockb'))) return 'bun';
if (await pathExists(path.join(repoPath, 'package-lock.json'))) return 'npm';
return 'unknown';
}
function detectStyleSystem(pkg: CodeImportIndex['packageJson']): CodeImportIndex['styleSystem'] {
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
if (deps['tailwindcss']) return 'tailwind';
if (deps['styled-components']) return 'styled-components';
if (deps['@emotion/react']) return 'emotion';
return 'unknown';
}
async function detectRoutes(
repoPath: string,
framework: CodeImportIndex['framework'],
): Promise<CodeImportIndex['routes']> {
if (framework === 'next') {
if (await pathExists(path.join(repoPath, 'app'))) return { kind: 'next-app' };
if (await pathExists(path.join(repoPath, 'pages'))) return { kind: 'next-pages' };
if (await pathExists(path.join(repoPath, 'src', 'app'))) return { kind: 'next-app' };
if (await pathExists(path.join(repoPath, 'src', 'pages'))) return { kind: 'next-pages' };
return { kind: 'unknown' };
}
if (framework === 'sveltekit') return { kind: 'sveltekit' };
if (framework === 'remix' || framework === 'vite') return { kind: 'vite-router' };
return undefined;
}
async function pathExists(p: string): Promise<boolean> {
try { await fsp.access(p); return true; } catch { return false; }
}
function extractImports(text: string): string[] {
const out: string[] = [];
const seen = new Set<string>();
IMPORT_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = IMPORT_RE.exec(text)) !== null) {
const spec = match[1];
if (!spec) continue;
if (seen.has(spec)) continue;
seen.add(spec);
out.push(spec);
}
return out;
}

View file

@ -0,0 +1,248 @@
// Phase 6/7 entry slice / spec §10 / §21.3.2 — design-extract atom.
//
// SKILL.md fragment ships at plugins/_official/atoms/design-extract/.
// The runner takes a project cwd that already has
// `<cwd>/code/index.json` (the output of the `code-import` atom) and
// scans every text file under the source repo for design tokens
// (colors, font families, spacing, radii, shadows). It writes the
// canonical bag the SKILL.md fragment promises:
//
// <cwd>/code/tokens.json
//
// The extractor is a heuristic, deliberately conservative pass —
// false negatives are preferable to false positives, because
// `token-map` then asks the human to confirm the mapping. The
// SKILL.md fragment documents this limitation so the agent doesn't
// claim the bag is exhaustive.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import type { CodeImportIndex } from './code-import.js';
export type DesignTokenKind = 'color' | 'typography' | 'spacing' | 'radius' | 'shadow';
export interface DesignTokenEntry {
kind: DesignTokenKind;
name?: string;
value: string;
sources: string[];
usage: string[];
}
export interface DesignExtractReport {
colors: DesignTokenEntry[];
typography: DesignTokenEntry[];
spacing: DesignTokenEntry[];
radius: DesignTokenEntry[];
shadow: DesignTokenEntry[];
// Files we touched. Pinned so `token-map.unmatched.json` can
// attribute "this token came from <file>" without re-scanning.
scannedFiles: string[];
warnings: string[];
endedAt: string;
}
export interface DesignExtractOptions {
// Project cwd containing code/index.json + the source files.
cwd: string;
// Repo root (where the imported source actually lives — typically
// distinct from cwd; the runner reads file contents via this path).
repoPath: string;
// Per-file size cap — files larger than this are skipped because
// the regex pass becomes O(n) on hundreds of MB of bundled JS.
// Default 256 KiB.
largeFileBytes?: number;
}
const DEFAULT_LARGE_FILE = 256 * 1024;
const HEX_COLOR_RE = /#[0-9a-fA-F]{3,8}\b/g;
const RGBA_COLOR_RE = /rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*[\d.]+)?\s*\)/g;
const HSLA_COLOR_RE = /hsla?\(\s*[\d.]+(?:deg|rad|turn)?\s*,\s*[\d.]+%?\s*,\s*[\d.]+%?(?:\s*,\s*[\d.]+)?\s*\)/g;
const VAR_COLOR_RE = /--([a-z][a-z0-9-]*-(?:color|fg|bg|accent|primary|secondary|surface|border|muted))\s*:\s*([^;\n]+)/g;
const FONT_FAMILY_RE = /font-family\s*:\s*([^;\n]+)/g;
const SPACING_PX_RE = /\b(?:padding|margin|gap|inset|top|left|right|bottom)\s*:\s*(\d+(?:\.\d+)?(?:px|rem|em))/g;
const RADIUS_RE = /border-radius\s*:\s*([^;\n]+)/g;
const SHADOW_RE = /box-shadow\s*:\s*([^;\n]+)/g;
const TAILWIND_HEX_RE = /['"]#[0-9a-fA-F]{3,8}['"]/g;
export async function runDesignExtract(opts: DesignExtractOptions): Promise<DesignExtractReport> {
const cwd = path.resolve(opts.cwd);
const repoPath = path.resolve(opts.repoPath);
const largeFileBytes = opts.largeFileBytes ?? DEFAULT_LARGE_FILE;
const indexPath = path.join(cwd, 'code', 'index.json');
const warnings: string[] = [];
let index: CodeImportIndex;
try {
const raw = await fsp.readFile(indexPath, 'utf8');
index = JSON.parse(raw) as CodeImportIndex;
} catch (err) {
throw new Error(`design-extract: missing or unreadable code/index.json (run code-import first): ${(err as Error).message}`);
}
// Per-token aggregation: dedupe by canonical value and collect
// the (path, line) sources + the file basenames usage[].
const colors: Map<string, DesignTokenEntry> = new Map();
const typography: Map<string, DesignTokenEntry> = new Map();
const spacing: Map<string, DesignTokenEntry> = new Map();
const radius: Map<string, DesignTokenEntry> = new Map();
const shadow: Map<string, DesignTokenEntry> = new Map();
const scannedFiles: string[] = [];
for (const entry of index.files) {
if (entry.size > largeFileBytes) continue;
const lang = entry.language;
if (lang !== 'css' && lang !== 'scss' && lang !== 'ts' && lang !== 'tsx' &&
lang !== 'js' && lang !== 'jsx' && lang !== 'html' && lang !== 'json') {
continue;
}
const abs = path.join(repoPath, entry.path);
let text: string;
try {
text = await fsp.readFile(abs, 'utf8');
} catch {
warnings.push(`unreadable: ${entry.path}`);
continue;
}
scannedFiles.push(entry.path);
extractColors(text, entry.path, colors);
extractCSSVariables(text, entry.path, colors);
extractTypography(text, entry.path, typography);
extractSpacing(text, entry.path, spacing);
extractRadius(text, entry.path, radius);
extractShadow(text, entry.path, shadow);
// Tailwind config / theme files are JS/TS — capture quoted hex
// colours that aren't picked up by HEX_COLOR_RE alone.
if (lang === 'js' || lang === 'ts') {
extractTailwindHexes(text, entry.path, colors);
}
}
if (scannedFiles.length === 0) {
warnings.push('design-extract scanned 0 files; check that code-import populated code/index.json');
}
const report: DesignExtractReport = {
colors: [...colors.values()].sort(byNameOrValue),
typography: [...typography.values()].sort(byNameOrValue),
spacing: [...spacing.values()].sort(byNameOrValue),
radius: [...radius.values()].sort(byNameOrValue),
shadow: [...shadow.values()].sort(byNameOrValue),
scannedFiles,
warnings,
endedAt: new Date().toISOString(),
};
await fsp.mkdir(path.join(cwd, 'code'), { recursive: true });
await fsp.writeFile(
path.join(cwd, 'code', 'tokens.json'),
JSON.stringify(report, null, 2) + '\n',
'utf8',
);
return report;
}
function byNameOrValue(a: DesignTokenEntry, b: DesignTokenEntry): number {
if (a.name && b.name && a.name !== b.name) return a.name.localeCompare(b.name);
return a.value.localeCompare(b.value);
}
function pushSource(map: Map<string, DesignTokenEntry>, key: string, kind: DesignTokenKind, value: string, source: string, name?: string) {
let entry = map.get(key);
if (!entry) {
entry = { kind, value, sources: [], usage: [] };
if (name) entry.name = name;
map.set(key, entry);
}
if (!entry.sources.includes(source)) entry.sources.push(source);
const basename = path.basename(source);
if (!entry.usage.includes(basename)) entry.usage.push(basename);
}
function extractColors(text: string, file: string, out: Map<string, DesignTokenEntry>): void {
for (const re of [HEX_COLOR_RE, RGBA_COLOR_RE, HSLA_COLOR_RE]) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
const value = m[0];
const line = lineNumberAt(text, m.index);
pushSource(out, `c:${value.toLowerCase()}`, 'color', value, `${file}:${line}`);
}
}
}
function extractCSSVariables(text: string, file: string, out: Map<string, DesignTokenEntry>): void {
VAR_COLOR_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = VAR_COLOR_RE.exec(text)) !== null) {
const name = `--${m[1]}`;
const value = (m[2] ?? '').trim();
if (!value) continue;
const line = lineNumberAt(text, m.index);
pushSource(out, `cv:${name}`, 'color', value, `${file}:${line}`, name);
}
}
function extractTypography(text: string, file: string, out: Map<string, DesignTokenEntry>): void {
FONT_FAMILY_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = FONT_FAMILY_RE.exec(text)) !== null) {
const value = (m[1] ?? '').replace(/['"]/g, '').trim();
if (!value) continue;
const line = lineNumberAt(text, m.index);
pushSource(out, `f:${value.toLowerCase()}`, 'typography', value, `${file}:${line}`);
}
}
function extractSpacing(text: string, file: string, out: Map<string, DesignTokenEntry>): void {
SPACING_PX_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = SPACING_PX_RE.exec(text)) !== null) {
const value = m[1] ?? '';
if (!value) continue;
const line = lineNumberAt(text, m.index);
pushSource(out, `s:${value}`, 'spacing', value, `${file}:${line}`);
}
}
function extractRadius(text: string, file: string, out: Map<string, DesignTokenEntry>): void {
RADIUS_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = RADIUS_RE.exec(text)) !== null) {
const value = (m[1] ?? '').trim();
if (!value) continue;
const line = lineNumberAt(text, m.index);
pushSource(out, `r:${value}`, 'radius', value, `${file}:${line}`);
}
}
function extractShadow(text: string, file: string, out: Map<string, DesignTokenEntry>): void {
SHADOW_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = SHADOW_RE.exec(text)) !== null) {
const value = (m[1] ?? '').trim();
if (!value) continue;
const line = lineNumberAt(text, m.index);
pushSource(out, `sh:${value}`, 'shadow', value, `${file}:${line}`);
}
}
function extractTailwindHexes(text: string, file: string, out: Map<string, DesignTokenEntry>): void {
TAILWIND_HEX_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = TAILWIND_HEX_RE.exec(text)) !== null) {
const raw = m[0]!;
const value = raw.slice(1, -1);
const line = lineNumberAt(text, m.index);
pushSource(out, `c:${value.toLowerCase()}`, 'color', value, `${file}:${line}`);
}
}
function lineNumberAt(text: string, index: number): number {
let line = 1;
for (let i = 0; i < index && i < text.length; i++) {
if (text.charCodeAt(i) === 10) line++;
}
return line;
}

View file

@ -0,0 +1,112 @@
// Phase 8 entry slice / spec §10.3 / §21.5 — diff-review GenUI bridge.
//
// Glue between the daemon's GenUI respond endpoint and the
// `runDiffReview()` helper. When the user (or the agent) answers the
// auto-derived `__auto_diff_review_<stageId>` choice surface, the
// daemon should immediately persist that decision into the run's
// project cwd as `<cwd>/review/decision.json` so the next pipeline
// stage (handoff atom) sees the user's decision without a second
// turn through the agent.
//
// This module is intentionally narrow:
// - `isDiffReviewSurfaceId(id)` — the only place the
// '__auto_diff_review_' prefix is owned, so future renames flow
// from one constant.
// - `parseDiffReviewGenuiResponse(value)` — coerces the JSON body
// the surface renderer submitted into the
// `runDiffReview({ decision })` shape, with strict validation.
// - `applyDiffReviewDecisionToCwd({ cwd, value, reviewer })` —
// end-to-end glue that calls runDiffReview() with the parsed
// decision and returns the report. Best-effort on the daemon
// side: failures are swallowed and logged so the GenUI respond
// route still returns 200.
//
// The bridge is filesystem-only (no SQLite). The respond endpoint
// owns the genui_surfaces row; this helper just persists the
// downstream review/decision.json + receipts.
import { runDiffReview, type DiffReviewer, type DiffReviewReport } from './diff-review.js';
import { runAndPersistHandoff, type RunAndPersistHandoffResult } from './handoff.js';
const DIFF_REVIEW_SURFACE_PREFIX = '__auto_diff_review_';
export function isDiffReviewSurfaceId(id: string): boolean {
return typeof id === 'string' && id.startsWith(DIFF_REVIEW_SURFACE_PREFIX);
}
export interface ParsedDiffReviewDecision {
decision: 'accept' | 'reject' | 'partial';
accepted_files?: string[];
rejected_files?: string[];
reason?: string;
}
export function parseDiffReviewGenuiResponse(value: unknown): ParsedDiffReviewDecision | { error: string } {
if (!value || typeof value !== 'object') {
return { error: 'diff-review response must be a JSON object' };
}
const v = value as Record<string, unknown>;
const decision = v.decision;
if (decision !== 'accept' && decision !== 'reject' && decision !== 'partial') {
return { error: `decision must be one of accept / reject / partial; got ${typeof decision === 'string' ? decision : typeof decision}` };
}
const out: ParsedDiffReviewDecision = { decision };
if (Array.isArray(v.accepted_files)) {
out.accepted_files = v.accepted_files.filter((s): s is string => typeof s === 'string');
}
if (Array.isArray(v.rejected_files)) {
out.rejected_files = v.rejected_files.filter((s): s is string => typeof s === 'string');
}
if (typeof v.reason === 'string' && v.reason.length > 0) out.reason = v.reason;
return out;
}
export interface ApplyDiffReviewDecisionInput {
cwd: string;
value: unknown;
reviewer: DiffReviewer;
}
export interface ApplyDiffReviewDecisionResult {
ok: true;
report: DiffReviewReport;
// Plan §3.T1 — when the diff-review write succeeds, we also run the
// handoff atom against the project cwd so `<cwd>/handoff/manifest.json`
// tracks the promotion ladder live. Best-effort: a failure here
// doesn't fail the overall bridge call (the diff-review write
// already succeeded).
handoff?: RunAndPersistHandoffResult;
handoffError?: string;
}
export interface ApplyDiffReviewDecisionFailure {
ok: false;
error: string;
}
export async function applyDiffReviewDecisionToCwd(
input: ApplyDiffReviewDecisionInput,
): Promise<ApplyDiffReviewDecisionResult | ApplyDiffReviewDecisionFailure> {
const parsed = parseDiffReviewGenuiResponse(input.value);
if ('error' in parsed) return { ok: false, error: parsed.error };
try {
const decision: NonNullable<Parameters<typeof runDiffReview>[0]['decision']> = {
decision: parsed.decision,
reviewer: input.reviewer,
};
if (parsed.accepted_files) decision.accepted_files = parsed.accepted_files;
if (parsed.rejected_files) decision.rejected_files = parsed.rejected_files;
if (parsed.reason) decision.reason = parsed.reason;
const report = await runDiffReview({ cwd: input.cwd, decision });
// Auto-promote `<cwd>/handoff/manifest.json` from the new
// decision + any prior build-test signals. Best-effort.
const result: ApplyDiffReviewDecisionResult = { ok: true, report };
try {
result.handoff = await runAndPersistHandoff({ cwd: input.cwd });
} catch (err) {
result.handoffError = (err as Error).message;
}
return result;
} catch (err) {
return { ok: false, error: (err as Error).message };
}
}

View file

@ -0,0 +1,233 @@
// Phase 7-8 entry slice / spec §20.3 / §21.3.2 — diff-review atom.
//
// SKILL.md fragment ships at plugins/_official/atoms/diff-review/.
// The runner walks the project cwd's `plan/receipts/` directory,
// re-reads each touched file's current state, and emits the four
// files the SKILL.md fragment promises:
//
// <cwd>/review/diff.patch — concatenation of every receipt's
// files in canonical 'unified diff'
// shape, derived from a snapshot of
// the original file content.
// <cwd>/review/summary.md — per-step walkthrough with stats.
// <cwd>/review/decision.json — { decision, accepted_files,
// rejected_files, reviewer }
// <cwd>/review/meta.json — { generatedAt, atomDigest,
// planRevision }
//
// `decision.json` is owned by the user-facing GenUI surface; this
// runner only generates the file when the caller passes an explicit
// decision (or when a previous decision exists at <cwd>/review/decision.json).
//
// The runner does NOT compute a real line-by-line diff — it relies
// on the receipts the patch-edit atom already wrote and just records
// the file list, before/after sizes, and added/removed counts. The
// SKILL.md fragment documents that the diff.patch artefact is a
// receipt-derived summary, not a precise hunk replay; precise hunks
// live in plan/receipts/<id>.json where the agent stored them.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { createHash } from 'node:crypto';
import type { PatchReceiptEntry, PatchStepRecord } from './patch-edit.js';
export type DiffReviewDecision = 'accept' | 'reject' | 'partial';
export type DiffReviewer = 'user' | 'agent';
export interface DiffReviewDecisionFile {
decision: DiffReviewDecision;
accepted_files: string[];
rejected_files: string[];
reviewer: DiffReviewer;
reason?: string;
decidedAt: string;
}
export interface DiffReviewMeta {
generatedAt: string;
atomDigest: string;
planRevision: number;
}
export interface DiffReviewReport {
files: string[];
added: number;
removed: number;
receipts: PatchReceiptEntry[];
decision?: DiffReviewDecisionFile;
meta: DiffReviewMeta;
}
export interface DiffReviewOptions {
cwd: string;
// Optional explicit decision. When omitted, the runner produces
// the diff/summary/meta artefacts but leaves decision.json
// untouched (so a GenUI surface can write it later).
decision?: {
decision: DiffReviewDecision;
reviewer: DiffReviewer;
accepted_files?: string[];
rejected_files?: string[];
reason?: string;
};
}
export async function runDiffReview(opts: DiffReviewOptions): Promise<DiffReviewReport> {
const cwd = path.resolve(opts.cwd);
const planDir = path.join(cwd, 'plan');
const receiptDir = path.join(planDir, 'receipts');
const reviewDir = path.join(cwd, 'review');
// Read steps.json so we can attribute each receipt to its step.
let steps: PatchStepRecord[] = [];
try {
steps = JSON.parse(await fsp.readFile(path.join(planDir, 'steps.json'), 'utf8')) as PatchStepRecord[];
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
let receiptFiles: string[] = [];
try {
receiptFiles = await fsp.readdir(receiptDir);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
const receipts: PatchReceiptEntry[] = [];
for (const fname of receiptFiles) {
if (!fname.startsWith('step-') || !fname.endsWith('.json')) continue;
try {
const r = JSON.parse(await fsp.readFile(path.join(receiptDir, fname), 'utf8')) as PatchReceiptEntry;
receipts.push(r);
} catch {
// skip malformed receipt
}
}
// Stable sort: by step id so the diff.patch concatenation is
// deterministic across runs.
receipts.sort((a, b) => a.step.localeCompare(b.step));
const fileSet = new Set<string>();
let added = 0;
let removed = 0;
for (const r of receipts) {
for (const f of r.files) fileSet.add(f);
added += r.added;
removed += r.removed;
}
const files = [...fileSet].sort();
const meta: DiffReviewMeta = {
generatedAt: new Date().toISOString(),
atomDigest: digestObject({ receipts, files }),
planRevision: steps.length,
};
await fsp.mkdir(reviewDir, { recursive: true });
await fsp.writeFile(path.join(reviewDir, 'diff.patch'), renderDiffPatch(receipts, steps), 'utf8');
await fsp.writeFile(path.join(reviewDir, 'summary.md'), renderSummary({ receipts, steps, added, removed }), 'utf8');
await fsp.writeFile(path.join(reviewDir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8');
let decisionFile: DiffReviewDecisionFile | undefined;
// Honour an existing decision.json on disk.
try {
const raw = await fsp.readFile(path.join(reviewDir, 'decision.json'), 'utf8');
decisionFile = JSON.parse(raw) as DiffReviewDecisionFile;
} catch {
decisionFile = undefined;
}
if (opts.decision) {
decisionFile = composeDecisionFile(opts.decision, files);
await fsp.writeFile(path.join(reviewDir, 'decision.json'), JSON.stringify(decisionFile, null, 2) + '\n', 'utf8');
}
const report: DiffReviewReport = {
files,
added,
removed,
receipts,
meta,
};
if (decisionFile) report.decision = decisionFile;
return report;
}
function composeDecisionFile(
input: NonNullable<DiffReviewOptions['decision']>,
allFiles: string[],
): DiffReviewDecisionFile {
let accepted: string[];
let rejected: string[];
if (input.decision === 'accept') {
accepted = input.accepted_files ?? allFiles.slice();
rejected = input.rejected_files ?? [];
} else if (input.decision === 'reject') {
accepted = [];
rejected = input.rejected_files ?? allFiles.slice();
} else {
accepted = input.accepted_files ?? [];
rejected = input.rejected_files ?? [];
}
// Spec invariant: on 'partial' the union must equal the touched
// file set so the reviewer cannot leave a file ambiguous.
if (input.decision === 'partial') {
const union = new Set([...accepted, ...rejected]);
for (const f of allFiles) if (!union.has(f)) {
throw new Error(`diff-review: 'partial' decision must cover every touched file; missing ${f}`);
}
}
const decisionFile: DiffReviewDecisionFile = {
decision: input.decision,
accepted_files: [...new Set(accepted)].sort(),
rejected_files: [...new Set(rejected)].sort(),
reviewer: input.reviewer,
decidedAt: new Date().toISOString(),
};
if (input.reason) decisionFile.reason = input.reason;
return decisionFile;
}
function renderDiffPatch(receipts: PatchReceiptEntry[], steps: PatchStepRecord[]): string {
const stepIndex = new Map(steps.map((s) => [s.id, s]));
const lines: string[] = [];
for (const r of receipts) {
const step = stepIndex.get(r.step);
lines.push(`# step: ${r.step}`);
if (step?.rationale) lines.push(`# rationale: ${step.rationale}`);
if (r.rationale) lines.push(`# patch-rationale: ${r.rationale}`);
lines.push(`# files: ${r.files.join(', ')}`);
lines.push(`# +${r.added} -${r.removed}`);
lines.push('');
}
return lines.join('\n');
}
function renderSummary(args: {
receipts: PatchReceiptEntry[];
steps: PatchStepRecord[];
added: number;
removed: number;
}): string {
const lines: string[] = [];
lines.push('# Patch review summary');
lines.push('');
lines.push(`- steps applied: ${args.receipts.length}`);
lines.push(`- lines added: ${args.added}`);
lines.push(`- lines removed: ${args.removed}`);
lines.push('');
for (const r of args.receipts) {
const step = args.steps.find((s) => s.id === r.step);
lines.push(`## ${r.step}`);
lines.push('');
if (step?.risk) lines.push(`- risk: ${step.risk}`);
if (step?.rationale) lines.push(`- step rationale: ${step.rationale}`);
if (r.rationale) lines.push(`- patch rationale: ${r.rationale}`);
lines.push(`- files (+${r.added} -${r.removed}):`);
for (const f of r.files) lines.push(` - \`${f}\``);
lines.push('');
}
return lines.join('\n');
}
function digestObject(obj: unknown): string {
return createHash('sha1').update(JSON.stringify(obj)).digest('hex');
}

View file

@ -0,0 +1,426 @@
// Phase 6 entry slice / spec §10 / §21.3.1 — figma-extract atom.
//
// SKILL.md fragment ships at plugins/_official/atoms/figma-extract/.
// The runner pulls a Figma file's node tree via the Figma REST API
// and writes the deterministic on-disk snapshot subsequent stages
// (`token-map`, `generate`, `critique`) operate on:
//
// <cwd>/figma/tree.json canonical node tree
// (id / name / type / parent / children /
// box / fills / text / componentRef)
// <cwd>/figma/tokens.json design-extract-shaped token bag
// (colors / typography / spacing /
// radius / shadow) so token-map can
// consume the figma flow with the same
// crosswalk it uses for code-migration.
// <cwd>/figma/assets/ rasterised exports per leaf node (the
// REST GET /v1/images call); when the
// atom runs in offline mode the directory
// stays empty.
// <cwd>/figma/meta.json { fileUrl, fileKey, version,
// lastModified, exportedAt,
// atomDigest, unsupportedNodes[] }
//
// Network is pluggable: callers pass `fetchFn` (defaults to
// `globalThis.fetch`). Tests + offline mode pass a fixture-backed
// stub. The atom never stores the OAuth token; it accepts the
// token as a parameter and forgets it after the call returns.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { createHash } from 'node:crypto';
import type { DesignExtractReport, DesignTokenEntry } from './design-extract.js';
export interface FigmaNode {
id: string;
name: string;
type: string;
parent?: string;
children?: string[];
// Bounding box in absolute Figma coords (px).
box?: { x: number; y: number; w: number; h: number };
// Concatenated text run for TEXT nodes; otherwise undefined.
text?: string;
// First fill colour as #RRGGBB(AA), if any. We only lift solid
// fills here; gradients / images stay on the raw node and surface
// through `unsupportedNodes` when we can't represent them.
fill?: string;
// Stroke colour as #RRGGBB(AA), if any.
stroke?: string;
cornerRadius?: number;
// Component instance pointer (so `token-map` can de-duplicate at
// the right boundary, per the SKILL.md fragment).
componentRef?: string;
}
export interface FigmaExtractReport {
tree: FigmaNode[];
tokens: DesignExtractReport;
meta: {
fileUrl: string;
fileKey: string;
version?: string;
lastModified?: string;
exportedAt: string;
atomDigest: string;
unsupportedNodes: Array<{ id: string; type: string; reason: string }>;
nodeCount: number;
};
}
export interface FigmaExtractOptions {
cwd: string;
// Either fileUrl or fileKey is required.
fileUrl?: string;
fileKey?: string;
// OAuth bearer token. Forwarded as 'Authorization: Bearer <t>'.
// The atom never persists it.
token: string;
// Optional pluggable fetch — tests pass a stub that returns
// canned JSON. Defaults to globalThis.fetch.
fetchFn?: typeof fetch;
// Offline mode: skip the GET /v1/images call (assets/ stays empty).
// Default true; the daemon flips it off when the run has
// network capability granted.
offlineAssets?: boolean;
// Asset format for the GET /v1/images call. Default 'svg'; the
// Figma REST API also accepts 'png' / 'jpg' / 'pdf'. Spec §10.3.1
// recommends 'svg' for fidelity + replay; binary fixtures only
// when an asset's source is rasterised in Figma to begin with.
assetFormat?: 'svg' | 'png' | 'jpg' | 'pdf';
// Per-asset download size cap (bytes). Default 5 MiB. Above the cap
// the asset is skipped + listed in meta.unsupportedNodes[] with
// reason='asset-too-large' so the human can audit.
assetMaxBytes?: number;
}
const FILE_URL_RE = /^https:\/\/(?:www\.)?figma\.com\/(?:file|design)\/([A-Za-z0-9]+)/;
export async function runFigmaExtract(opts: FigmaExtractOptions): Promise<FigmaExtractReport> {
const cwd = path.resolve(opts.cwd);
const fileKey = opts.fileKey ?? extractFileKey(opts.fileUrl);
if (!fileKey) {
throw new Error('figma-extract: missing fileKey or fileUrl (Figma file URL must match https://figma.com/file/<KEY>)');
}
if (!opts.token) {
throw new Error('figma-extract: missing OAuth token (route through oauth-prompt with connectorId=figma)');
}
const fetchFn = opts.fetchFn ?? globalThis.fetch;
if (!fetchFn) throw new Error('figma-extract: no fetch implementation available');
// 1. GET /v1/files/<key> — full document tree.
const url = `https://api.figma.com/v1/files/${encodeURIComponent(fileKey)}`;
const res = await fetchFn(url, {
headers: { 'Authorization': `Bearer ${opts.token}` },
});
if (!res.ok) {
const text = await safeText(res);
throw new Error(`figma-extract: ${res.status} ${res.statusText} from ${url}: ${text}`);
}
const body = await res.json() as FigmaApiFileResponse;
const unsupportedNodes: FigmaExtractReport['meta']['unsupportedNodes'] = [];
const tree: FigmaNode[] = [];
walkNode(body.document, undefined, tree, unsupportedNodes);
const tokens = liftTokens(tree);
const meta: FigmaExtractReport['meta'] = {
fileUrl: opts.fileUrl ?? `https://figma.com/file/${fileKey}`,
fileKey,
exportedAt: new Date().toISOString(),
atomDigest: digestObject({ tree, tokens }),
unsupportedNodes,
nodeCount: tree.length,
};
if (body.version) meta.version = body.version;
if (body.lastModified) meta.lastModified = body.lastModified;
// 2. Persist.
const figmaDir = path.join(cwd, 'figma');
const assetsDir = path.join(figmaDir, 'assets');
await fsp.mkdir(figmaDir, { recursive: true });
await fsp.mkdir(assetsDir, { recursive: true });
await fsp.writeFile(path.join(figmaDir, 'tree.json'), JSON.stringify(tree, null, 2) + '\n', 'utf8');
await fsp.writeFile(path.join(figmaDir, 'tokens.json'), JSON.stringify(tokens, null, 2) + '\n', 'utf8');
// 3. Asset rasterisation pass — GET /v1/images/<key>?ids=<ids>&format=<fmt>.
//
// Honoured only when offlineAssets !== true. Spec §10.3.1: asset
// exports cover every leaf node the file marks for export; v1
// lifts every leaf node we have a box for and lets the human
// prune from `figma/assets/<id>.<ext>` later.
const assetCandidates = pickAssetCandidates(tree);
if (opts.offlineAssets !== true && assetCandidates.length > 0) {
const assetFormat = opts.assetFormat ?? 'svg';
const assetMaxBytes = opts.assetMaxBytes ?? 5 * 1024 * 1024;
const assetIssues = await downloadAssets({
fileKey,
nodeIds: assetCandidates.map((c) => c.id),
token: opts.token,
fetchFn,
assetsDir,
assetFormat,
assetMaxBytes,
});
for (const issue of assetIssues) unsupportedNodes.push(issue);
meta.unsupportedNodes = unsupportedNodes;
// Re-derive atomDigest now that assets/ has settled (the digest
// is over the JSON shape, not the binary blobs, so this stays
// pure even with the on-disk side effects above).
meta.atomDigest = digestObject({ tree, tokens, assetIssues });
}
await fsp.writeFile(path.join(figmaDir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8');
return { tree, tokens, meta };
}
interface AssetCandidate { id: string; type: string }
function pickAssetCandidates(tree: FigmaNode[]): AssetCandidate[] {
const out: AssetCandidate[] = [];
// We pick visible TEXT-less leaf nodes (no children) that have a
// bounding box and aren't the document / canvas root. The daemon
// already filtered out invisible nodes upstream.
for (const n of tree) {
if (n.type === 'DOCUMENT' || n.type === 'CANVAS') continue;
if (n.children && n.children.length > 0) continue;
if (!n.box) continue;
if (n.text) continue; // skip pure text nodes; the agent renders text natively
out.push({ id: n.id, type: n.type });
}
return out;
}
interface FigmaApiImagesResponse {
err?: string | null;
images?: Record<string, string | null>;
}
async function downloadAssets(args: {
fileKey: string;
nodeIds: string[];
token: string;
fetchFn: typeof fetch;
assetsDir: string;
assetFormat: 'svg' | 'png' | 'jpg' | 'pdf';
assetMaxBytes: number;
}): Promise<FigmaExtractReport['meta']['unsupportedNodes']> {
const issues: FigmaExtractReport['meta']['unsupportedNodes'] = [];
// Figma API caps at ~100 ids per call; chunk for safety.
const chunkSize = 50;
const chunks: string[][] = [];
for (let i = 0; i < args.nodeIds.length; i += chunkSize) {
chunks.push(args.nodeIds.slice(i, i + chunkSize));
}
for (const chunk of chunks) {
const url = `https://api.figma.com/v1/images/${encodeURIComponent(args.fileKey)}?ids=${encodeURIComponent(chunk.join(','))}&format=${args.assetFormat}`;
let res: Response;
try {
res = await args.fetchFn(url, { headers: { 'Authorization': `Bearer ${args.token}` } });
} catch (err) {
for (const id of chunk) {
issues.push({ id, type: 'asset', reason: `image fetch error: ${(err as Error).message}` });
}
continue;
}
if (!res.ok) {
const text = await safeText(res);
for (const id of chunk) {
issues.push({ id, type: 'asset', reason: `${res.status} ${res.statusText} ${text}`.trim() });
}
continue;
}
const body = await res.json() as FigmaApiImagesResponse;
if (body.err) {
for (const id of chunk) issues.push({ id, type: 'asset', reason: `figma error: ${body.err}` });
continue;
}
const images = body.images ?? {};
for (const id of chunk) {
const downloadUrl = images[id];
if (typeof downloadUrl !== 'string' || !downloadUrl) {
issues.push({ id, type: 'asset', reason: 'no download URL returned' });
continue;
}
try {
const dl = await args.fetchFn(downloadUrl);
if (!dl.ok) {
issues.push({ id, type: 'asset', reason: `download ${dl.status} ${dl.statusText}` });
continue;
}
const buf = Buffer.from(await dl.arrayBuffer());
if (buf.byteLength > args.assetMaxBytes) {
issues.push({ id, type: 'asset', reason: `asset-too-large (${buf.byteLength} bytes)` });
continue;
}
const ext = args.assetFormat === 'jpg' ? 'jpg' : args.assetFormat;
const safeId = id.replace(/[^A-Za-z0-9_:-]+/g, '-');
await fsp.writeFile(path.join(args.assetsDir, `${safeId}.${ext}`), buf);
} catch (err) {
issues.push({ id, type: 'asset', reason: `download error: ${(err as Error).message}` });
}
}
}
return issues;
}
function extractFileKey(fileUrl: string | undefined): string | undefined {
if (!fileUrl) return undefined;
const m = FILE_URL_RE.exec(fileUrl);
return m ? m[1] : undefined;
}
interface FigmaApiFileResponse {
document: FigmaApiNode;
version?: string;
lastModified?: string;
components?: Record<string, { name: string }>;
}
interface FigmaApiNode {
id: string;
name: string;
type: string;
children?: FigmaApiNode[];
absoluteBoundingBox?: { x: number; y: number; width: number; height: number };
fills?: Array<{ type: string; color?: { r: number; g: number; b: number; a?: number }; opacity?: number; visible?: boolean }>;
strokes?: Array<{ type: string; color?: { r: number; g: number; b: number; a?: number }; opacity?: number }>;
cornerRadius?: number;
characters?: string;
componentId?: string;
visible?: boolean;
}
function walkNode(
node: FigmaApiNode,
parent: string | undefined,
out: FigmaNode[],
unsupported: FigmaExtractReport['meta']['unsupportedNodes'],
): void {
const entry: FigmaNode = {
id: node.id,
name: node.name,
type: node.type,
};
if (parent) entry.parent = parent;
if (node.absoluteBoundingBox) {
entry.box = {
x: node.absoluteBoundingBox.x,
y: node.absoluteBoundingBox.y,
w: node.absoluteBoundingBox.width,
h: node.absoluteBoundingBox.height,
};
}
const fill = pickSolidColor(node.fills);
if (fill) entry.fill = fill;
const stroke = pickSolidColor(node.strokes);
if (stroke) entry.stroke = stroke;
if (typeof node.cornerRadius === 'number') entry.cornerRadius = node.cornerRadius;
if (node.type === 'TEXT' && typeof node.characters === 'string') entry.text = node.characters;
if (node.componentId) entry.componentRef = node.componentId;
// Capture unsupported node types (gradients / image fills / vector
// boolean ops). We mark the node id but still include it on the
// tree so downstream atoms see the structure.
if (Array.isArray(node.fills)) {
for (const f of node.fills) {
if (f.type !== 'SOLID' && (f.visible ?? true)) {
unsupported.push({ id: node.id, type: node.type, reason: `unsupported fill type: ${f.type}` });
break;
}
}
}
if (Array.isArray(node.children) && node.children.length > 0) {
entry.children = node.children.map((c) => c.id);
}
out.push(entry);
if (Array.isArray(node.children)) {
for (const child of node.children) {
if ((child as FigmaApiNode).visible === false) continue;
walkNode(child, node.id, out, unsupported);
}
}
}
function pickSolidColor(fills: FigmaApiNode['fills']): string | undefined {
if (!Array.isArray(fills) || fills.length === 0) return undefined;
for (const f of fills) {
if (f.type !== 'SOLID') continue;
if ((f.visible ?? true) === false) continue;
if (!f.color) continue;
const r = clamp255(f.color.r);
const g = clamp255(f.color.g);
const b = clamp255(f.color.b);
const aRaw = (f.color.a ?? 1) * (f.opacity ?? 1);
const a = Math.max(0, Math.min(1, aRaw));
return a < 1
? `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(Math.round(a * 255))}`
: `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
return undefined;
}
function clamp255(v: number): number { return Math.max(0, Math.min(255, Math.round(v * 255))); }
function toHex(v: number): string { return v.toString(16).padStart(2, '0'); }
function liftTokens(tree: FigmaNode[]): DesignExtractReport {
const colors: Map<string, DesignTokenEntry> = new Map();
const radius: Map<string, DesignTokenEntry> = new Map();
const spacing: Map<string, DesignTokenEntry> = new Map();
const typography: Map<string, DesignTokenEntry> = new Map();
for (const n of tree) {
if (n.fill) pushToken(colors, 'color', n.fill, `${n.id}:fill`, n.name);
if (n.stroke) pushToken(colors, 'color', n.stroke, `${n.id}:stroke`, n.name);
if (typeof n.cornerRadius === 'number') {
const value = `${n.cornerRadius}px`;
pushToken(radius, 'radius', value, `${n.id}:cornerRadius`, n.name);
}
if (n.box && (n.type === 'FRAME' || n.type === 'GROUP')) {
// Surface frame width/height as spacing candidates the agent
// can audit. We tag with a hash so the source pointer is
// stable across runs.
const h = `${n.box.h}px`;
pushToken(spacing, 'spacing', h, `${n.id}:height`, n.name);
}
}
const out: DesignExtractReport = {
colors: [...colors.values()].sort(byNameOrValue),
typography: [...typography.values()].sort(byNameOrValue),
spacing: [...spacing.values()].sort(byNameOrValue),
radius: [...radius.values()].sort(byNameOrValue),
shadow: [],
scannedFiles: [],
warnings: [],
endedAt: new Date().toISOString(),
};
return out;
}
function pushToken(map: Map<string, DesignTokenEntry>, kind: DesignTokenEntry['kind'], value: string, source: string, name?: string): void {
const key = `${kind}:${value.toLowerCase()}`;
let entry = map.get(key);
if (!entry) {
entry = { kind, value, sources: [], usage: [] };
if (name) entry.name = name;
map.set(key, entry);
}
if (!entry.sources.includes(source)) entry.sources.push(source);
if (name && !entry.usage.includes(name)) entry.usage.push(name);
}
function byNameOrValue(a: DesignTokenEntry, b: DesignTokenEntry): number {
if (a.name && b.name && a.name !== b.name) return a.name.localeCompare(b.name);
return a.value.localeCompare(b.value);
}
async function safeText(res: Response): Promise<string> {
try { return (await res.text()).slice(0, 256); } catch { return ''; }
}
function digestObject(obj: unknown): string {
return createHash('sha1').update(JSON.stringify(obj)).digest('hex');
}

View file

@ -0,0 +1,330 @@
// Phase 7-8 entry slice / spec §10 / §11.5.1 / §21.5 — handoff atom.
//
// SKILL.md fragment lives at plugins/_official/atoms/handoff/. The
// daemon-side helper updates an ArtifactManifest's provenance +
// distribution metadata so subsequent runs (and the CLI's
// `od plugin export`) can reverse-resolve the artifact's lineage
// without mutating any prior fields. The contract is append-only:
//
// - sourcePluginSnapshotId NEVER changes after first write.
// - exportTargets[] / deployTargets[] only ever GROW.
// - handoffKind can be promoted (e.g. 'patch' → 'deployable-app')
// when build-test signals + diff-review acceptance combine.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import type {
ArtifactDeployTarget,
ArtifactExportTarget,
ArtifactManifest,
ArtifactProvenanceHandoffKind,
} from '@open-design/contracts';
export interface RecordHandoffInput {
manifest: ArtifactManifest;
exportTarget?: ArtifactExportTarget;
deployTarget?: ArtifactDeployTarget;
handoffKind?: ArtifactProvenanceHandoffKind;
// When true (default), refuse to demote handoffKind back along the
// axis 'design-only' < 'implementation-plan' < 'patch' < 'deployable-app'.
// Setting false lets a roll-back path explicitly downgrade after a
// failed deploy.
enforceMonotonicHandoff?: boolean;
}
export interface RecordHandoffResult {
manifest: ArtifactManifest;
changed: Array<'exportTargets' | 'deployTargets' | 'handoffKind'>;
}
const HANDOFF_RANK: Record<ArtifactProvenanceHandoffKind, number> = {
'design-only': 0,
'implementation-plan': 1,
'patch': 2,
'deployable-app': 3,
};
export function recordHandoff(input: RecordHandoffInput): RecordHandoffResult {
const changed: RecordHandoffResult['changed'] = [];
// Clone shallowly so the caller's reference isn't mutated; arrays
// we touch get fresh copies before we push.
const next: ArtifactManifest = { ...input.manifest };
if (input.exportTarget) {
const incoming = input.exportTarget;
const existing = next.exportTargets ?? [];
// Idempotency: a (surface, target) pair only ever lands once.
const already = existing.some(
(t: ArtifactExportTarget) => t.surface === incoming.surface && t.target === incoming.target,
);
if (!already) {
next.exportTargets = [...existing, incoming];
changed.push('exportTargets');
}
}
if (input.deployTarget) {
const incoming = input.deployTarget;
const existing = next.deployTargets ?? [];
const already = existing.some(
(t: ArtifactDeployTarget) => t.provider === incoming.provider && t.location === incoming.location,
);
if (!already) {
next.deployTargets = [...existing, incoming];
changed.push('deployTargets');
}
}
if (input.handoffKind) {
const enforce = input.enforceMonotonicHandoff ?? true;
const current = next.handoffKind;
if (!current) {
next.handoffKind = input.handoffKind;
changed.push('handoffKind');
} else {
const incomingRank = HANDOFF_RANK[input.handoffKind] ?? 0;
const currentRank = HANDOFF_RANK[current] ?? 0;
if (!enforce || incomingRank >= currentRank) {
if (current !== input.handoffKind) {
next.handoffKind = input.handoffKind;
changed.push('handoffKind');
}
}
}
}
return { manifest: next, changed };
}
// Spec §11.5.1 promotion rule for the deployable-app tier:
// `handoffKind: 'deployable-app'` requires:
// - build.passing (the build-test atom emitted true)
// - tests.passing (same)
// - at least one exportTargets[] entry on a 'docker' or 'cli' surface
// (i.e. the patch was actually packaged for delivery)
//
// This helper computes the eligibility flag so the handoff atom's
// caller can promote in one place rather than leaking the rule into
// every plugin.
export function isDeployableAppEligible(args: {
manifest: ArtifactManifest;
buildPassing?: boolean;
testsPassing?: boolean;
}): boolean {
if (args.buildPassing !== true) return false;
if (args.testsPassing !== true) return false;
const exports = args.manifest.exportTargets ?? [];
return exports.some((t: ArtifactExportTarget) => t.surface === 'docker' || t.surface === 'cli');
}
// Plan §3.S1 — pipeline-driven handoff bridge.
//
// Reads the on-disk state previous atoms wrote (critique/build-test.json,
// review/decision.json) and returns the updated manifest with the right
// handoffKind / exportTargets[] / signals attached. The function is
// pure relative to its inputs (it reads files, never writes back). The
// caller decides where to persist the updated manifest (typically
// `<cwd>/<manifest-path>` or `.od/artifacts/<id>/manifest.json`).
//
// Promotion ladder (spec §11.5.1):
// 1. decision='reject' → handoffKind='design-only'
// 2. decision='accept'/'partial' AND no build-test → handoffKind='implementation-plan'
// 3. (2) + build.passing OR tests.passing → handoffKind='patch'
// 4. (3) + build.passing && tests.passing + docker/cli exportTarget
// → handoffKind='deployable-app'
//
// Monotonicity is enforced via recordHandoff() — a subsequent run
// can only advance, never demote.
export interface RunHandoffAtomInput {
cwd: string;
manifest: ArtifactManifest;
// Optional explicit export target the caller is recording (e.g.
// 'cli' when od plugin export wrote to disk; 'docker' when the
// tools-pack image build completes; 'figma' when Figma export
// wrote a frame back).
exportTarget?: ArtifactExportTarget;
exportTargets?: ArtifactExportTarget[];
deployTarget?: ArtifactDeployTarget;
deployTargets?: ArtifactDeployTarget[];
}
export interface RunHandoffAtomResult {
manifest: ArtifactManifest;
changed: Array<'exportTargets' | 'deployTargets' | 'handoffKind'>;
signals: {
decision?: 'accept' | 'reject' | 'partial';
buildPassing?: boolean;
testsPassing?: boolean;
deployable: boolean;
};
}
export async function runHandoffAtom(input: RunHandoffAtomInput): Promise<RunHandoffAtomResult> {
const cwd = path.resolve(input.cwd);
const decision = await readDiffReviewDecision(cwd);
const buildTest = await readBuildTestSignals(cwd);
// Start by appending whatever explicit export/deploy targets the
// caller passed in. Idempotency is enforced inside recordHandoff().
let next = input.manifest;
let changed: RunHandoffAtomResult['changed'] = [];
const targets: ArtifactExportTarget[] = [
...(input.exportTarget ? [input.exportTarget] : []),
...(input.exportTargets ?? []),
];
for (const t of targets) {
const out = recordHandoff({ manifest: next, exportTarget: t });
next = out.manifest;
for (const c of out.changed) if (!changed.includes(c)) changed.push(c);
}
const deploys: ArtifactDeployTarget[] = [
...(input.deployTarget ? [input.deployTarget] : []),
...(input.deployTargets ?? []),
];
for (const d of deploys) {
const out = recordHandoff({ manifest: next, deployTarget: d });
next = out.manifest;
for (const c of out.changed) if (!changed.includes(c)) changed.push(c);
}
// Compute the target handoff kind from the on-disk state.
let handoffKind: ArtifactProvenanceHandoffKind | undefined;
if (decision === 'reject') {
handoffKind = 'design-only';
} else if (decision === 'accept' || decision === 'partial') {
handoffKind = 'implementation-plan';
if (buildTest && (buildTest.buildPassing || buildTest.testsPassing)) {
handoffKind = 'patch';
}
if (buildTest && isDeployableAppEligible({
manifest: next,
buildPassing: buildTest.buildPassing,
testsPassing: buildTest.testsPassing,
})) {
handoffKind = 'deployable-app';
}
}
if (handoffKind) {
const out = recordHandoff({ manifest: next, handoffKind });
next = out.manifest;
for (const c of out.changed) if (!changed.includes(c)) changed.push(c);
}
const signals: RunHandoffAtomResult['signals'] = {
deployable: handoffKind === 'deployable-app',
};
if (decision) signals.decision = decision;
if (buildTest) {
signals.buildPassing = buildTest.buildPassing;
signals.testsPassing = buildTest.testsPassing;
}
return { manifest: next, changed, signals };
}
async function readDiffReviewDecision(cwd: string): Promise<'accept' | 'reject' | 'partial' | undefined> {
const p = path.join(cwd, 'review', 'decision.json');
try {
const raw = await fsp.readFile(p, 'utf8');
const obj = JSON.parse(raw) as { decision?: unknown };
if (obj.decision === 'accept' || obj.decision === 'reject' || obj.decision === 'partial') {
return obj.decision;
}
return undefined;
} catch {
return undefined;
}
}
// Plan §3.T1 — `<cwd>/handoff/manifest.json` round-trip.
//
// runAndPersistHandoff() is the on-disk shell around runHandoffAtom():
//
// 1. Read `<cwd>/handoff/manifest.json` (or fall back to
// `manifestSeed`, or finally a minimal default) so promotions
// stay monotonic across re-runs.
// 2. Call runHandoffAtom() with the chosen seed + the caller's
// explicit export/deploy targets.
// 3. Write the updated manifest back to
// `<cwd>/handoff/manifest.json`.
// 4. Return the report shape the caller can forward to SSE / CLI
// audit logs.
//
// The bridge fires from the diff-review GenUI flow and from any
// pipeline runner that wants the manifest computed declaratively.
export interface RunAndPersistHandoffInput {
cwd: string;
// When the manifest file is missing AND no exportTargets are
// declared, fall back to this seed. Useful for the first pipeline
// run: the agent hasn't produced a manifest yet but we still want
// to record the promotion ladder progress.
manifestSeed?: ArtifactManifest;
exportTarget?: ArtifactExportTarget;
exportTargets?: ArtifactExportTarget[];
deployTarget?: ArtifactDeployTarget;
deployTargets?: ArtifactDeployTarget[];
}
export interface RunAndPersistHandoffResult extends RunHandoffAtomResult {
manifestPath: string;
// 'created' = no on-disk manifest existed; 'updated' = round-tripped;
// 'skipped' = nothing changed (re-run was a no-op).
persistMode: 'created' | 'updated' | 'skipped';
}
const DEFAULT_MANIFEST_SEED: ArtifactManifest = {
version: 1,
kind: 'react-component',
title: 'Pipeline output',
entry: '',
renderer: 'react-component',
exports: [],
};
export async function runAndPersistHandoff(
input: RunAndPersistHandoffInput,
): Promise<RunAndPersistHandoffResult> {
const cwd = path.resolve(input.cwd);
const manifestPath = path.join(cwd, 'handoff', 'manifest.json');
const existing = await readManifestFile(manifestPath);
const seed: ArtifactManifest = existing ?? input.manifestSeed ?? DEFAULT_MANIFEST_SEED;
const handoffInput: RunHandoffAtomInput = { cwd, manifest: seed };
if (input.exportTarget) handoffInput.exportTarget = input.exportTarget;
if (input.exportTargets) handoffInput.exportTargets = input.exportTargets;
if (input.deployTarget) handoffInput.deployTarget = input.deployTarget;
if (input.deployTargets) handoffInput.deployTargets = input.deployTargets;
const report = await runHandoffAtom(handoffInput);
let persistMode: RunAndPersistHandoffResult['persistMode'];
if (!existing) {
await fsp.mkdir(path.dirname(manifestPath), { recursive: true });
await fsp.writeFile(manifestPath, JSON.stringify(report.manifest, null, 2) + '\n', 'utf8');
persistMode = 'created';
} else if (report.changed.length > 0) {
await fsp.writeFile(manifestPath, JSON.stringify(report.manifest, null, 2) + '\n', 'utf8');
persistMode = 'updated';
} else {
persistMode = 'skipped';
}
return { ...report, manifestPath, persistMode };
}
async function readManifestFile(p: string): Promise<ArtifactManifest | undefined> {
try {
const raw = await fsp.readFile(p, 'utf8');
return JSON.parse(raw) as ArtifactManifest;
} catch {
return undefined;
}
}
async function readBuildTestSignals(cwd: string): Promise<{ buildPassing: boolean; testsPassing: boolean } | undefined> {
const p = path.join(cwd, 'critique', 'build-test.json');
try {
const raw = await fsp.readFile(p, 'utf8');
const obj = JSON.parse(raw) as { signals?: { 'build.passing'?: unknown; 'tests.passing'?: unknown } };
const buildPassing = obj.signals?.['build.passing'] === true;
const testsPassing = obj.signals?.['tests.passing'] === true;
return { buildPassing, testsPassing };
} catch {
return undefined;
}
}

View file

@ -0,0 +1,446 @@
// Phase 7 entry slice / spec §20.3 / §21.3.2 — patch-edit atom.
//
// SKILL.md fragment ships at plugins/_official/atoms/patch-edit/.
// The runner applies a unified diff to a project cwd one step at a
// time, enforcing the safety contract:
//
// - The diff may only touch files listed in the matching
// plan/steps.json entry.
// - It may NOT touch a file classified as 'shell' tier in
// plan/ownership.json unless the matching step's risk='high'.
// - It refuses to introduce new files outside the step's files[].
// - It writes per-step receipts under plan/receipts/<id>.json.
// - It updates plan/steps.json's status field per step
// (pending → completed | skipped | failed).
//
// The diff format is a strict subset of unified diff:
//
// --- a/path/to/file
// +++ b/path/to/file
// @@ -start,len +start,len @@
// ␣context line
// -removed line
// +added line
//
// Multiple files are supported in one patch; the parser splits on
// `--- a/` markers. For new-file creation, callers use:
//
// --- /dev/null
// +++ b/path/to/new-file
// @@ -0,0 +1,N @@
// +new line 1
// +new line 2
//
// For file deletion:
//
// --- a/path/to/old-file
// +++ /dev/null
// @@ -1,N +0,0 @@
// -old line
//
// The applier validates context lines to catch stale patches.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { randomBytes } from 'node:crypto';
import type { OwnershipEntry, RewriteStep } from './rewrite-plan.js';
// Plan §3.Z1 — atomic file write helper.
//
// Writes the body to a sibling temp file (`<path>.<random>.tmp`)
// and renames into place. POSIX rename(2) is atomic when source
// and destination are on the same filesystem, so a partial write
// never leaves the consumer staring at a half-truncated file.
//
// The temp file is removed on the failure path so a crash mid-
// write doesn't leak an orphan tmp blob next to every plan step.
async function atomicWriteFile(target: string, body: string | Buffer): Promise<void> {
const dir = path.dirname(target);
const base = path.basename(target);
const tmp = path.join(dir, `${base}.${randomBytes(6).toString('hex')}.tmp`);
await fsp.mkdir(dir, { recursive: true });
try {
await fsp.writeFile(tmp, body);
await fsp.rename(tmp, target);
} catch (err) {
// Best-effort cleanup of the orphan tmp file. We intentionally
// swallow the unlink error; rename failure is what the caller
// needs to see.
try { await fsp.unlink(tmp); } catch { /* tmp may not exist */ }
throw err;
}
}
export type PatchStepStatus = 'pending' | 'completed' | 'skipped' | 'failed';
export interface PatchStepRecord extends Omit<RewriteStep, 'rationale'> {
rationale?: string;
status?: PatchStepStatus;
completedAt?: string;
}
export interface PatchReceiptEntry {
step: string;
files: string[];
added: number;
removed: number;
rationale: string;
completedAt: string;
}
export interface ApplyPatchInput {
// Project cwd (the directory plan/steps.json lives under and the
// diff applies inside).
cwd: string;
// The step id in plan/steps.json this patch is supposed to satisfy.
stepId: string;
// The unified diff text. May contain multiple file hunks.
diff: string;
// Free-text rationale recorded into the receipt.
rationale?: string;
// When true (default false), allow file creation outside the
// step's files[]. Typical use: tests adding fixtures.
allowOutOfStepCreation?: boolean;
}
export interface ApplyPatchResult {
status: 'completed' | 'skipped' | 'failed';
filesTouched: string[];
added: number;
removed: number;
// When status='failed' or 'skipped', a structured reason the
// pipeline runner can surface to the agent.
reason?: string;
}
export interface SkipStepInput {
cwd: string;
stepId: string;
rationale: string;
}
export async function applyPatchForStep(input: ApplyPatchInput): Promise<ApplyPatchResult> {
const cwd = path.resolve(input.cwd);
const stepsPath = path.join(cwd, 'plan', 'steps.json');
const ownershipPath = path.join(cwd, 'plan', 'ownership.json');
const steps = await readJson<PatchStepRecord[]>(stepsPath, 'patch-edit: missing plan/steps.json (run rewrite-plan first)');
const ownership = await readJson<OwnershipEntry[]>(ownershipPath, 'patch-edit: missing plan/ownership.json (run rewrite-plan first)').catch(() => [] as OwnershipEntry[]);
const step = steps.find((s) => s.id === input.stepId);
if (!step) {
return { status: 'failed', filesTouched: [], added: 0, removed: 0, reason: `unknown step '${input.stepId}'` };
}
if (step.status === 'completed' || step.status === 'skipped' || step.status === 'failed') {
return { status: step.status === 'completed' ? 'completed' : 'skipped', filesTouched: [], added: 0, removed: 0,
reason: `step already in terminal state '${step.status}'` };
}
const allowedFiles = new Set(step.files);
const ownershipMap = new Map(ownership.map((o) => [o.file, o.layer]));
const hunks = parseUnifiedDiff(input.diff);
if (hunks.length === 0) {
return { status: 'failed', filesTouched: [], added: 0, removed: 0, reason: 'patch contained no hunks' };
}
// Validate before touching disk: every hunk's target file must be
// in the step's files[] (unless allowOutOfStepCreation), and shell
// tier files require risk='high'.
for (const hunk of hunks) {
const target = hunk.targetFile;
const source = hunk.sourceFile;
// For deletions, source carries the file id and target is null.
// For everything else, target is the touched file.
const touched = target ?? source;
if (!touched) {
return { status: 'failed', filesTouched: [], added: 0, removed: 0, reason: 'hunk missing both source and target file' };
}
const isCreate = source === null;
if (!allowedFiles.has(touched)) {
if (!(isCreate && input.allowOutOfStepCreation)) {
return { status: 'failed', filesTouched: [], added: 0, removed: 0,
reason: `hunk targets ${touched} which is not in step.files[]` };
}
}
const layer = ownershipMap.get(touched);
if (layer === 'shell' && step.risk !== 'high') {
return { status: 'failed', filesTouched: [], added: 0, removed: 0,
reason: `step risk='${step.risk}' may not touch shell-tier file ${touched}; promote risk to 'high' or move the change` };
}
}
// Apply each hunk.
const filesTouched = new Set<string>();
let added = 0;
let removed = 0;
for (const hunk of hunks) {
try {
const result = await applyOneFileHunks(cwd, hunk);
filesTouched.add(hunk.targetFile ?? hunk.sourceFile!);
added += result.added;
removed += result.removed;
} catch (err) {
const touched = hunk.targetFile ?? hunk.sourceFile ?? '<unknown>';
return { status: 'failed', filesTouched: [...filesTouched], added, removed,
reason: `hunk apply failed for ${touched}: ${(err as Error).message}` };
}
}
// Mark the step completed in plan/steps.json + write a receipt.
step.status = 'completed';
step.completedAt = new Date().toISOString();
if (input.rationale) step.rationale = input.rationale;
await atomicWriteFile(stepsPath, JSON.stringify(steps, null, 2) + '\n');
const receiptDir = path.join(cwd, 'plan', 'receipts');
await fsp.mkdir(receiptDir, { recursive: true });
const receipt: PatchReceiptEntry = {
step: step.id,
files: [...filesTouched].sort(),
added,
removed,
rationale: input.rationale ?? '',
completedAt: step.completedAt,
};
await atomicWriteFile(
path.join(receiptDir, `step-${step.id}.json`),
JSON.stringify(receipt, null, 2) + '\n',
);
return { status: 'completed', filesTouched: [...filesTouched], added, removed };
}
export async function skipStep(input: SkipStepInput): Promise<void> {
const cwd = path.resolve(input.cwd);
const stepsPath = path.join(cwd, 'plan', 'steps.json');
const steps = await readJson<PatchStepRecord[]>(stepsPath, 'patch-edit: missing plan/steps.json');
const step = steps.find((s) => s.id === input.stepId);
if (!step) throw new Error(`patch-edit: unknown step '${input.stepId}'`);
step.status = 'skipped';
step.completedAt = new Date().toISOString();
step.rationale = input.rationale;
await atomicWriteFile(stepsPath, JSON.stringify(steps, null, 2) + '\n');
const receiptDir = path.join(cwd, 'plan', 'receipts');
await fsp.mkdir(receiptDir, { recursive: true });
const receipt: PatchReceiptEntry = {
step: step.id,
files: [],
added: 0,
removed: 0,
rationale: input.rationale,
completedAt: step.completedAt,
};
await atomicWriteFile(
path.join(receiptDir, `step-${step.id}.json`),
JSON.stringify(receipt, null, 2) + '\n',
);
}
// --- diff parsing -----------------------------------------------------
interface FileHunkBundle {
sourceFile: string | null; // null when /dev/null (file creation)
targetFile: string | null; // null when /dev/null (file deletion)
hunks: ParsedHunk[];
}
interface ParsedHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
body: string[]; // diff body lines (excluding the @@ header)
}
function parseUnifiedDiff(diff: string): FileHunkBundle[] {
// Split on '\n--- ' markers (keeping the first marker on the
// leading section). We also accept input that begins with '---'
// directly.
const lines = diff.split(/\r?\n/);
const bundles: FileHunkBundle[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i] ?? '';
if (!line.startsWith('--- ')) { i++; continue; }
const sourcePath = parsePathLine(line);
const next = lines[i + 1] ?? '';
if (!next.startsWith('+++ ')) {
throw new Error(`expected '+++ ' after '--- ' at line ${i + 1}`);
}
const targetPath = parsePathLine(next);
i += 2;
const hunks: ParsedHunk[] = [];
while (i < lines.length) {
const cursor = lines[i] ?? '';
if (cursor.startsWith('--- ')) break;
if (cursor.startsWith('@@ ')) {
const header = parseHunkHeader(cursor);
i++;
const body: string[] = [];
while (i < lines.length) {
const c = lines[i] ?? '';
if (c.startsWith('--- ') || c.startsWith('@@ ')) break;
body.push(c);
i++;
}
hunks.push({ ...header, body });
continue;
}
i++;
}
bundles.push({
sourceFile: sourcePath === '/dev/null' ? null : sourcePath,
targetFile: targetPath === '/dev/null' ? null : targetPath,
hunks,
});
}
return bundles;
}
function parsePathLine(line: string): string {
const after = line.slice(4).trim();
if (after === '/dev/null') return '/dev/null';
// Strip the 'a/' or 'b/' prefix when present.
if (after.startsWith('a/')) return after.slice(2);
if (after.startsWith('b/')) return after.slice(2);
return after;
}
function parseHunkHeader(line: string): { oldStart: number; oldLines: number; newStart: number; newLines: number } {
const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
if (!m) throw new Error(`malformed hunk header: ${line}`);
return {
oldStart: parseInt(m[1]!, 10),
oldLines: m[2] ? parseInt(m[2], 10) : 1,
newStart: parseInt(m[3]!, 10),
newLines: m[4] ? parseInt(m[4], 10) : 1,
};
}
function resolvePatchFile(cwd: string, file: string): string {
const unsafe = `unsafe path '${file}'`;
if (file.includes('\0')) throw new Error(unsafe);
if (/^[A-Za-z]:/.test(file)) throw new Error(unsafe);
if (path.isAbsolute(file) || path.win32.isAbsolute(file) || path.posix.isAbsolute(file)) {
throw new Error(unsafe);
}
if (file.replace(/\\/g, '/').split('/').some((segment) => segment === '..')) {
throw new Error(unsafe);
}
const abs = path.resolve(cwd, file);
const relative = path.relative(cwd, abs);
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(unsafe);
}
return abs;
}
async function applyOneFileHunks(cwd: string, bundle: FileHunkBundle): Promise<{ added: number; removed: number }> {
if (bundle.sourceFile === null && bundle.targetFile === null) {
throw new Error('hunk has /dev/null on both sides');
}
const sourceAbs = bundle.sourceFile === null ? null : resolvePatchFile(cwd, bundle.sourceFile);
const targetAbs = bundle.targetFile === null ? null : resolvePatchFile(cwd, bundle.targetFile);
if (bundle.targetFile === null) {
// File deletion.
let body: string;
try { body = await fsp.readFile(sourceAbs!, 'utf8'); } catch { throw new Error(`file not found: ${bundle.sourceFile}`); }
await fsp.unlink(sourceAbs!);
return { added: 0, removed: body.split('\n').length };
}
if (bundle.sourceFile === null) {
// File creation.
await fsp.mkdir(path.dirname(targetAbs!), { recursive: true });
let added = 0;
const lines: string[] = [];
for (const hunk of bundle.hunks) {
for (const l of hunk.body) {
if (l.startsWith('+')) { lines.push(l.slice(1)); added++; }
else if (l === '' || l.startsWith('\\')) { /* trailing newline marker */ }
}
}
await atomicWriteFile(targetAbs!, lines.join('\n') + (lines.length > 0 ? '\n' : ''));
return { added, removed: 0 };
}
// Plain edit.
let original: string;
try { original = await fsp.readFile(targetAbs!, 'utf8'); } catch { throw new Error(`file not found: ${bundle.targetFile}`); }
const originalLines = original.split('\n');
// Trailing newline produces an empty last element after split; we
// preserve that and add it back at the end.
const trailingNL = original.endsWith('\n');
if (trailingNL) originalLines.pop();
// Apply hunks in reverse order so prior offsets stay valid.
const hunks = [...bundle.hunks].sort((a, b) => b.oldStart - a.oldStart);
let added = 0;
let removed = 0;
let working = originalLines.slice();
for (const hunk of hunks) {
const oldIndex = hunk.oldStart - 1;
const result = applyHunkBody(working, oldIndex, hunk.body);
added += result.added;
removed += result.removed;
working = result.working;
}
const final = working.join('\n') + (trailingNL ? '\n' : '');
await atomicWriteFile(targetAbs!, final);
return { added, removed };
}
function applyHunkBody(lines: string[], oldIndex: number, body: string[]): { working: string[]; added: number; removed: number } {
// Walk the body: '-' / ' ' lines must match `lines[i]`; '+' lines
// are inserts.
const before = lines.slice(0, oldIndex);
const after: string[] = [];
const replacement: string[] = [];
let cursor = oldIndex;
let added = 0;
let removed = 0;
for (const raw of body) {
if (raw === '' || raw.startsWith('\\')) continue;
const tag = raw[0];
const content = raw.slice(1);
if (tag === ' ') {
if (lines[cursor] !== content) {
throw new Error(`context mismatch at line ${cursor + 1}: expected ${JSON.stringify(content)} got ${JSON.stringify(lines[cursor])}`);
}
replacement.push(content);
cursor++;
} else if (tag === '-') {
if (lines[cursor] !== content) {
throw new Error(`removal mismatch at line ${cursor + 1}: expected ${JSON.stringify(content)} got ${JSON.stringify(lines[cursor])}`);
}
cursor++;
removed++;
} else if (tag === '+') {
replacement.push(content);
added++;
} else {
// Unknown line tag; treat as context for safety.
replacement.push(raw);
cursor++;
}
}
for (const l of lines.slice(cursor)) after.push(l);
return { working: [...before, ...replacement, ...after], added, removed };
}
async function readJson<T>(p: string, missingMsg: string): Promise<T> {
try {
return JSON.parse(await fsp.readFile(p, 'utf8')) as T;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') throw new Error(missingMsg);
throw err;
}
}
// --- aggregate progress helper ---------------------------------------
export async function readPlanProgress(cwd: string): Promise<{ total: number; terminal: number }> {
const stepsPath = path.join(cwd, 'plan', 'steps.json');
const steps = await readJson<PatchStepRecord[]>(stepsPath, 'plan/steps.json missing');
const terminal = steps.filter((s) => s.status === 'completed' || s.status === 'skipped' || s.status === 'failed').length;
return { total: steps.length, terminal };
}

View file

@ -0,0 +1,144 @@
// Plan §3.D — atom worker registry.
//
// Stage D of plugin-driven-flow-plan replaces the canned stub stage
// runner inside `firePipelineForRun` with a per-atom worker model.
// Each atom can register a `run(ctx)` that observes the run's DB
// state and returns real `UntilSignals`; atoms with no registered
// worker fall through silently so the stage runner keeps converging
// at parity with the v1 stub for unobserved pipelines.
//
// The registry stays intentionally minimal:
// - module-level `Map<atomId, AtomWorker>` so registration is a
// simple side-effect (built-ins.ts registers FIRST_PARTY_ATOMS
// on first use)
// - `runStageWithRegistry(ctx)` walks `stage.atoms`, asks each
// registered worker for its signals, then pessimistically
// merges them (lowest number / false-wins boolean) so a single
// atom returning `critique.score: 2` overrides the optimistic
// defaults
// - permissive defaults (`critique.score: 4`, `preview.ok: true`,
// `user.confirmed: true`) keep the happy path converging in one
// iteration when no atom contradicts them — matches v1 stub
// behaviour for backwards compatibility.
import type Database from 'better-sqlite3';
import type {
AppliedPluginSnapshot,
PipelineStage,
} from '@open-design/contracts';
import type { UntilSignals } from '../until.js';
type SqliteDb = Database.Database;
export interface AtomWorkerContext {
db: SqliteDb;
runId: string;
projectId: string;
conversationId: string | null;
stage: PipelineStage;
iteration: number;
snapshot: AppliedPluginSnapshot;
}
export interface AtomOutcome {
signals?: UntilSignals;
// Optional free-form note appended to the stage's
// `run_devloop_iterations.critique_summary` audit column.
note?: string;
}
export interface AtomWorker {
id: string;
describe?: string;
run: (ctx: AtomWorkerContext) => Promise<AtomOutcome> | AtomOutcome;
}
const REGISTRY = new Map<string, AtomWorker>();
export function registerAtomWorker(worker: AtomWorker): void {
REGISTRY.set(worker.id, worker);
}
export function unregisterAtomWorker(id: string): void {
REGISTRY.delete(id);
}
export function clearAtomWorkers(): void {
REGISTRY.clear();
}
export function getAtomWorker(id: string): AtomWorker | undefined {
return REGISTRY.get(id);
}
export function listRegisteredAtomIds(): string[] {
return Array.from(REGISTRY.keys()).sort();
}
// Permissive defaults mirror the v1 stub's canned signals so the
// registry runner stays at parity for unobserved atoms — swapping
// from stub → registry never regresses happy-path convergence.
// Real worker observations REPLACE these defaults wholesale (rather
// than min-merging) so a real score of 5 never gets clipped to the
// default 4; cross-worker conflicts inside a single stage still
// pessimistically merge (false-wins / lowest-number-wins).
export const PERMISSIVE_DEFAULT_SIGNALS: Readonly<UntilSignals> = Object.freeze({
'critique.score': 4,
'preview.ok': true,
'user.confirmed': true,
});
export interface StageRegistryOutcome {
signals: UntilSignals;
critiqueSummary: string | null;
notes: string[];
observedAtoms: string[];
}
// Walk every atom in the stage, invoke its registered worker (if
// any), then layer the resulting real-observation map over the
// permissive defaults. Worker failures are captured as notes and
// never crash the stage — the devloop scheduler keeps its
// iteration cap as the safety net.
export async function runStageWithRegistry(
ctx: AtomWorkerContext,
): Promise<StageRegistryOutcome> {
const real = new Map<keyof UntilSignals, unknown>();
const notes: string[] = [];
const observed: string[] = [];
for (const atomId of ctx.stage.atoms ?? []) {
const worker = getAtomWorker(atomId);
if (!worker) continue;
observed.push(atomId);
try {
const out = await Promise.resolve(worker.run(ctx));
for (const [k, v] of Object.entries(out.signals ?? {})) {
const key = k as keyof UntilSignals;
const prev = real.get(key);
real.set(key, prev === undefined ? v : mergePessimistic(prev, v));
}
if (out.note) notes.push(`[${worker.id}] ${out.note}`);
} catch (err) {
notes.push(`[${worker.id}] worker error: ${(err as Error).message ?? String(err)}`);
}
}
const accumulated: UntilSignals = { ...PERMISSIVE_DEFAULT_SIGNALS };
for (const [key, value] of real) {
accumulated[key] = value as never;
}
return {
signals: accumulated,
critiqueSummary: notes.length > 0 ? notes.join('\n') : null,
notes,
observedAtoms: observed,
};
}
// Pessimistic merge between multiple workers contributing to the
// same signal key. Cross-worker false-wins / lowest-number-wins
// so a single failing gate still surfaces as a failed convergence.
function mergePessimistic(prev: unknown, next: unknown): unknown {
if (typeof prev === 'boolean' && typeof next === 'boolean') return prev && next;
if (typeof prev === 'number' && typeof next === 'number') return Math.min(prev, next);
return next;
}

View file

@ -0,0 +1,311 @@
// Phase 7 entry slice / spec §20.3 / §21.3.2 — rewrite-plan atom.
//
// SKILL.md fragment ships at plugins/_official/atoms/rewrite-plan/.
// Given a project cwd that already has code/index.json (from
// code-import) + an optional code/tokens.json (from design-extract),
// the runner produces a heuristic ownership classification and a
// per-component step list. The narrative `plan.md` is intentionally
// short — it's a scaffold the agent overwrites once the LLM-driven
// stage runs; the heuristic baseline gives subsequent stages
// (`patch-edit` / `diff-review` / `build-test`) an audit trail to
// reference even if the LLM step is skipped or fails.
//
// Ownership tiers (spec §11.5.1 / §20.3):
// leaf — single-component leaf files (Button.tsx, Card.css)
// shared — shared infrastructure (hooks/, lib/, utils/)
// route — page-level / route entry points (app/page.tsx,
// pages/index.tsx, src/app/(group)/page.tsx)
// shell — top-level layout / framework boundaries
// (layout.tsx, _app.tsx, providers/, root css)
//
// The classifier keeps false-positive `shell` rare: only files that
// match a strict allowlist (layout|root|provider|theme|tokens|globals)
// are tagged `shell`. Everything else collapses to `leaf` so the
// patch-edit safety gate doesn't lock the agent out of plain
// component edits.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { createHash } from 'node:crypto';
import type { CodeImportIndex } from './code-import.js';
import type { DesignExtractReport } from './design-extract.js';
export type OwnershipTier = 'leaf' | 'shared' | 'route' | 'shell';
export interface OwnershipEntry {
file: string;
layer: OwnershipTier;
}
export interface RewriteStep {
id: string;
files: string[];
rationale: string;
risk: 'low' | 'medium' | 'high';
}
export interface RewritePlanReport {
steps: RewriteStep[];
ownership: OwnershipEntry[];
// SHA-1 digests of the inputs we relied on. The handoff atom
// promotes these into ArtifactManifest provenance so a reviewer
// can prove "this plan was generated against this snapshot of
// code-import / token-map".
meta: {
generatedAt: string;
atomDigest: string; // hash of canonicalised code/index.json
tokenMapDigest: string; // hash of code/tokens.json (or 'none')
intent: string; // user-supplied intent string (echoed)
};
// The narrative plan.md scaffold the runner emits. The agent is
// expected to overwrite this with its own narrative; we ship a
// baseline so downstream stages always see a non-empty file.
planMarkdown: string;
}
export interface RewritePlanOptions {
cwd: string;
// The user's brief, copied into plan.md and into steps[].rationale
// when the heuristic can't think of anything better.
intent?: string;
// Override the build-test step id name (default 'build-test').
buildTestStepId?: string;
}
const SHELL_BASENAMES = new Set([
'layout.tsx', 'layout.jsx', 'layout.ts', 'layout.js',
'_app.tsx', '_app.jsx', '_app.ts', '_app.js',
'_document.tsx', '_document.jsx',
'providers.tsx', 'providers.tsx', 'providers.ts',
'theme.ts', 'theme.tsx',
'globals.css', 'global.css',
'tokens.css', 'design-tokens.css',
]);
const ROUTE_DIR_HINT = /(?:^|\/)(?:app|pages)\//;
const ROUTE_BASENAME = /^(?:page|index|route)\.[tj]sx?$/;
const SHARED_DIR_HINT = /(?:^|\/)(?:hooks|lib|utils|providers|context|store|stores|services|api|shared|common)\//;
const COMPONENT_DIR_HINT = /(?:^|\/)components?\//;
export async function runRewritePlan(opts: RewritePlanOptions): Promise<RewritePlanReport> {
const cwd = path.resolve(opts.cwd);
const indexPath = path.join(cwd, 'code', 'index.json');
const tokensPath = path.join(cwd, 'code', 'tokens.json');
const intent = (opts.intent ?? '').trim();
const buildTestStepId = opts.buildTestStepId ?? 'build-test';
let index: CodeImportIndex;
try {
index = JSON.parse(await fsp.readFile(indexPath, 'utf8')) as CodeImportIndex;
} catch (err) {
throw new Error(`rewrite-plan: missing or unreadable code/index.json (run code-import first): ${(err as Error).message}`);
}
let tokens: DesignExtractReport | undefined;
try {
tokens = JSON.parse(await fsp.readFile(tokensPath, 'utf8')) as DesignExtractReport;
} catch {
tokens = undefined;
}
const ownership = classifyOwnership(index);
const steps = composeSteps({ index, ownership, tokens, intent, buildTestStepId });
const meta = {
generatedAt: new Date().toISOString(),
atomDigest: digestObject(canonicaliseIndex(index)),
tokenMapDigest: tokens ? digestObject(tokens) : 'none',
intent: intent || '',
};
const planMarkdown = renderNarrative({ intent, ownership, steps, tokens });
// Persist all four files under <cwd>/plan/.
const planDir = path.join(cwd, 'plan');
await fsp.mkdir(planDir, { recursive: true });
await fsp.writeFile(path.join(planDir, 'plan.md'), planMarkdown, 'utf8');
await fsp.writeFile(path.join(planDir, 'ownership.json'), JSON.stringify(ownership, null, 2) + '\n', 'utf8');
await fsp.writeFile(path.join(planDir, 'steps.json'), JSON.stringify(steps, null, 2) + '\n', 'utf8');
await fsp.writeFile(path.join(planDir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8');
return { steps, ownership, meta, planMarkdown };
}
function classifyOwnership(index: CodeImportIndex): OwnershipEntry[] {
const out: OwnershipEntry[] = [];
for (const f of index.files) {
out.push({ file: f.path, layer: classifyOne(f.path) });
}
// Ownership is sorted lexicographically so the JSON output is
// diff-friendly across runs.
out.sort((a, b) => a.file.localeCompare(b.file));
return out;
}
function classifyOne(file: string): OwnershipTier {
const base = path.posix.basename(file);
if (SHELL_BASENAMES.has(base)) return 'shell';
if (ROUTE_DIR_HINT.test(file) && ROUTE_BASENAME.test(base)) return 'route';
// Layout files in the App Router that aren't in SHELL_BASENAMES list:
if (ROUTE_DIR_HINT.test(file) && /^layout\.[tj]sx?$/.test(base)) return 'shell';
if (SHARED_DIR_HINT.test(file)) return 'shared';
if (COMPONENT_DIR_HINT.test(file)) return 'leaf';
// Files at the repo root that aren't shell are usually config — keep them
// as `shared` so the patch-edit gate insists on `risk: 'medium'+'.
if (!file.includes('/')) return 'shared';
return 'leaf';
}
function composeSteps(args: {
index: CodeImportIndex;
ownership: OwnershipEntry[];
tokens: DesignExtractReport | undefined;
intent: string;
buildTestStepId: string;
}): RewriteStep[] {
const steps: RewriteStep[] = [];
// Step 0: token alignment when design-extract found anything. The
// step's files[] enumerates leaf files that contain hex literals;
// patch-edit replaces them with the active DS token references.
if (args.tokens && (args.tokens.colors.length > 0 || args.tokens.spacing.length > 0)) {
const filesWithTokens = new Set<string>();
for (const t of args.tokens.colors) for (const s of t.sources) filesWithTokens.add(s.split(':')[0]!);
for (const t of args.tokens.spacing) for (const s of t.sources) filesWithTokens.add(s.split(':')[0]!);
if (filesWithTokens.size > 0) {
steps.push({
id: 'tokens-alignment',
files: [...filesWithTokens].sort(),
rationale: 'Replace inline literal colours / spacing with active design-system tokens; keep semantic shape unchanged.',
risk: 'low',
});
}
}
// Step 1..N: per-leaf-component step (one step per leaf file in
// the components/ tree). Each step bundles the leaf file with any
// sibling stylesheet of the same basename.
const leafFiles = args.ownership.filter((o) => o.layer === 'leaf' && /\.(?:tsx|jsx|ts|js|vue|svelte)$/.test(o.file));
for (const f of leafFiles) {
const sibling = findSiblingStylesheet(args.index, f.file);
const files = sibling ? [f.file, sibling] : [f.file];
steps.push({
id: `rewrite-${slug(f.file)}`,
files,
rationale: `Rewrite ${f.file} per the user's intent${args.intent ? `: ${args.intent}` : ''}.`,
risk: 'low',
});
}
// Step N+1: shared / route refactors when present, marked medium.
const sharedRouteFiles = args.ownership
.filter((o) => o.layer === 'shared' || o.layer === 'route')
.map((o) => o.file);
if (sharedRouteFiles.length > 0) {
steps.push({
id: 'shared-and-route-touchups',
files: sharedRouteFiles,
rationale: 'Update shared infrastructure / route entry points to reflect leaf rewrites; cross-cutting changes only.',
risk: 'medium',
});
}
// Final step: build-test gate. patch-edit refuses to mark the
// pipeline converged without this step's file list reaching
// build.passing && tests.passing.
steps.push({
id: args.buildTestStepId,
files: [],
rationale: 'Run typecheck + tests; iterate until build.passing && tests.passing.',
risk: 'low',
});
return steps;
}
function findSiblingStylesheet(index: CodeImportIndex, file: string): string | undefined {
const dir = path.posix.dirname(file);
const base = path.posix.basename(file).replace(/\.[^.]+$/, '');
const candidates = [`${base}.css`, `${base}.scss`, `${base}.module.css`];
for (const f of index.files) {
const fdir = path.posix.dirname(f.path);
if (fdir !== dir) continue;
if (candidates.includes(path.posix.basename(f.path))) return f.path;
}
return undefined;
}
function slug(file: string): string {
return file
.replace(/^.*\//, '')
.replace(/\.[^.]+$/, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function canonicaliseIndex(index: CodeImportIndex): unknown {
// Strip the walk-time fields (walkedAt / walkBudgetMs) and
// skipped[] so the digest only changes when the file roster
// changes. Otherwise re-walks would invalidate every plan even
// when the source tree is identical.
return {
framework: index.framework,
packageManager: index.packageManager,
styleSystem: index.styleSystem,
routes: index.routes,
files: index.files
.map((f) => ({ path: f.path, language: f.language, size: f.size, imports: f.imports ?? [] }))
.sort((a, b) => a.path.localeCompare(b.path)),
};
}
function digestObject(obj: unknown): string {
return createHash('sha1').update(JSON.stringify(obj)).digest('hex');
}
function renderNarrative(args: {
intent: string;
ownership: OwnershipEntry[];
steps: RewriteStep[];
tokens: DesignExtractReport | undefined;
}): string {
const lines: string[] = [];
lines.push('# Rewrite plan');
lines.push('');
if (args.intent) {
lines.push(`**Intent**: ${args.intent}`);
lines.push('');
}
const tierCount = (tier: OwnershipTier) => args.ownership.filter((o) => o.layer === tier).length;
lines.push('## Ownership snapshot');
lines.push('');
lines.push(`- shell: ${tierCount('shell')} files`);
lines.push(`- route: ${tierCount('route')} files`);
lines.push(`- shared: ${tierCount('shared')} files`);
lines.push(`- leaf: ${tierCount('leaf')} files`);
lines.push('');
if (args.tokens) {
lines.push('## Design tokens detected');
lines.push('');
lines.push(`- colors: ${args.tokens.colors.length}`);
lines.push(`- typography: ${args.tokens.typography.length}`);
lines.push(`- spacing: ${args.tokens.spacing.length}`);
lines.push(`- radius: ${args.tokens.radius.length}`);
lines.push(`- shadow: ${args.tokens.shadow.length}`);
lines.push('');
}
lines.push('## Steps');
lines.push('');
for (const step of args.steps) {
lines.push(`### ${step.id} — risk: ${step.risk}`);
lines.push('');
lines.push(step.rationale);
if (step.files.length > 0) {
lines.push('');
for (const f of step.files) lines.push(`- \`${f}\``);
}
lines.push('');
}
return lines.join('\n');
}

View file

@ -0,0 +1,412 @@
// Phase 6/7 entry slice / spec §10 / §21.3.1 — token-map atom.
//
// SKILL.md fragment ships at plugins/_official/atoms/token-map/.
// The runner crosswalks an extracted token bag (output of
// `design-extract` for code-migration, or `figma-extract` for
// figma-migration) onto the active OD design system's token
// vocabulary and writes the canonical mapping the SKILL.md fragment
// promises:
//
// <cwd>/token-map/colors.json
// <cwd>/token-map/typography.json
// <cwd>/token-map/spacing.json
// <cwd>/token-map/radius.json
// <cwd>/token-map/shadow.json
// <cwd>/token-map/unmatched.json — { source, reason }[]
// <cwd>/token-map/meta.json — { sourceKind, generatedAt,
// atomDigest, designSystemId? }
//
// Match strategy (deterministic, in this order):
// 1. Exact value match (#abc === #abc).
// 2. Normalised hex (#abc → #aabbcc, ignore case).
// 3. Named source token AND a target with a matching name (e.g.
// --primary-500 → ds-primary-500). Fuzzy match strips '--' /
// 'ds-' prefixes and lower-cases.
//
// Anything else lands in unmatched[] with one of the reasons:
// 'no-target-equivalent' — no target with the same value/name.
// 'target-collision' — multiple sources map to the same target
// (kept for the first source; subsequent
// are listed unmatched with a hint).
// 'invalid-source' — source token value is malformed.
//
// The atom is intentionally conservative: it never invents targets,
// never relies on perceptual proximity (the SKILL.md fragment routes
// that to the visual-diff evaluator). False negatives are preferable
// to false positives — `unmatched.json` is the audit list the user
// reviews.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { createHash } from 'node:crypto';
import type { DesignExtractReport, DesignTokenEntry, DesignTokenKind } from './design-extract.js';
export interface DesignSystemToken {
// Canonical token name (e.g. 'ds-primary-500', '--ds-color-fg').
name: string;
// Token kind. Loose because design systems vary (e.g. some collapse
// 'spacing' + 'radius' into one scale); we mirror the same five
// kinds design-extract emits.
kind: DesignTokenKind;
value: string;
// Optional human description — surfaced in unmatched.json hints.
description?: string;
}
export interface DesignSystemTokenBag {
// Daemon-side caller fills this from the active design system's
// DESIGN.md / tokens.json.
id?: string;
tokens: DesignSystemToken[];
}
export interface TokenMapMatch {
source: string; // raw input value (or token name when present)
sourceName?: string;
target: string; // matched target token name
targetValue: string;
via: 'exact' | 'normalised-hex' | 'name';
kind: DesignTokenKind;
// The source token's audit trail (file:line entries) so a reviewer
// can audit "which target was chosen for which call site".
sources: string[];
}
export interface TokenMapUnmatched {
source: string;
sourceName?: string;
kind: DesignTokenKind;
reason: 'no-target-equivalent' | 'target-collision' | 'invalid-source';
hint?: string;
}
export interface TokenMapReport {
colors: TokenMapMatch[];
typography: TokenMapMatch[];
spacing: TokenMapMatch[];
radius: TokenMapMatch[];
shadow: TokenMapMatch[];
unmatched: TokenMapUnmatched[];
meta: {
sourceKind: 'figma' | 'code';
generatedAt: string;
atomDigest: string;
designSystemId?: string;
targetTokenCount: number;
sourceTokenCount: number;
matchedTokenCount: number;
};
}
export interface TokenMapOptions {
cwd: string;
// Source bag. When omitted, the runner reads <cwd>/code/tokens.json
// (preferred for code-migration) or falls back to
// <cwd>/figma/tokens.json (figma-migration).
source?: { kind: 'figma' | 'code'; report: DesignExtractReport };
// The active design system's tokens. Caller supplies this; the atom
// never reads filesystem directly so it stays unit-testable.
designSystem: DesignSystemTokenBag;
// Strict mode aborts when ANY source token can't be mapped.
// Default 'soft' — populates unmatched[] and continues.
strict?: boolean;
}
const HEX_RE = /^#([0-9a-fA-F]{3,8})$/;
export async function runTokenMap(opts: TokenMapOptions): Promise<TokenMapReport> {
const cwd = path.resolve(opts.cwd);
// Resolve source.
let sourceKind: 'figma' | 'code';
let source: DesignExtractReport;
if (opts.source) {
sourceKind = opts.source.kind;
source = opts.source.report;
} else {
const codePath = path.join(cwd, 'code', 'tokens.json');
const figmaPath = path.join(cwd, 'figma', 'tokens.json');
if (await pathExists(codePath)) {
sourceKind = 'code';
source = JSON.parse(await fsp.readFile(codePath, 'utf8')) as DesignExtractReport;
} else if (await pathExists(figmaPath)) {
sourceKind = 'figma';
source = JSON.parse(await fsp.readFile(figmaPath, 'utf8')) as DesignExtractReport;
} else {
throw new Error(`token-map: missing both code/tokens.json and figma/tokens.json (run design-extract or figma-extract first)`);
}
}
const targets = indexDesignSystem(opts.designSystem.tokens);
const unmatched: TokenMapUnmatched[] = [];
const claimed: Map<string, TokenMapMatch> = new Map();
const buckets = {
colors: [] as TokenMapMatch[],
typography: [] as TokenMapMatch[],
spacing: [] as TokenMapMatch[],
radius: [] as TokenMapMatch[],
shadow: [] as TokenMapMatch[],
};
let sourceTokenCount = 0;
let matchedTokenCount = 0;
for (const kind of ['colors', 'typography', 'spacing', 'radius', 'shadow'] as const) {
for (const entry of source[kind]) {
sourceTokenCount++;
const result = matchOne(kind, entry, targets);
if (!result.match) {
unmatched.push(result.unmatched);
continue;
}
const claimKey = result.match.target;
if (claimed.has(claimKey)) {
// Spec §21.3.1 target-collision: the second source claiming
// the same target lands unmatched with a hint pointing at
// the first claimant.
const first = claimed.get(claimKey)!;
unmatched.push({
source: result.match.source,
...(result.match.sourceName ? { sourceName: result.match.sourceName } : {}),
kind: result.match.kind,
reason: 'target-collision',
hint: `target ${claimKey} already mapped from ${first.source}`,
});
continue;
}
claimed.set(claimKey, result.match);
buckets[kind as keyof typeof buckets].push(result.match);
matchedTokenCount++;
}
}
if (opts.strict && unmatched.length > 0) {
throw new Error(`token-map (strict): ${unmatched.length} source tokens unmatched`);
}
// Stable sort each bucket: by source value first, then by target.
for (const k of Object.keys(buckets) as (keyof typeof buckets)[]) {
buckets[k].sort((a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target));
}
unmatched.sort((a, b) => a.kind.localeCompare(b.kind) || a.source.localeCompare(b.source));
const meta: TokenMapReport['meta'] = {
sourceKind,
generatedAt: new Date().toISOString(),
atomDigest: digestObject({ buckets, unmatched }),
targetTokenCount: opts.designSystem.tokens.length,
sourceTokenCount,
matchedTokenCount,
};
if (opts.designSystem.id) meta.designSystemId = opts.designSystem.id;
const report: TokenMapReport = { ...buckets, unmatched, meta };
await fsp.mkdir(path.join(cwd, 'token-map'), { recursive: true });
for (const k of Object.keys(buckets) as (keyof typeof buckets)[]) {
await fsp.writeFile(
path.join(cwd, 'token-map', `${k}.json`),
JSON.stringify(buckets[k], null, 2) + '\n',
'utf8',
);
}
await fsp.writeFile(path.join(cwd, 'token-map', 'unmatched.json'), JSON.stringify(unmatched, null, 2) + '\n', 'utf8');
await fsp.writeFile(path.join(cwd, 'token-map', 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8');
return report;
}
// --- DESIGN.md token extraction (heuristic, used by daemon callers) ---
// Parse a DESIGN.md body for token declarations. Lifts:
// - CSS custom property declarations (`--ds-color-fg: #111`)
// - Markdown table rows of shape `| name | value | …`
// The result is best-effort; daemon callers may pass a hand-curated
// token list instead.
export function parseDesignSystemTokens(body: string): DesignSystemToken[] {
const out: DesignSystemToken[] = [];
const seen = new Set<string>();
// CSS custom properties.
const cssRe = /--([a-z][a-z0-9-]*)\s*:\s*([^;\n]+)/g;
let m: RegExpExecArray | null;
while ((m = cssRe.exec(body)) !== null) {
const name = `--${m[1]}`;
const value = (m[2] ?? '').trim();
if (!value) continue;
const key = `${name}=${value}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ name, value, kind: classifyKind(name, value) });
}
// Markdown table rows. We require at least three pipes per line and
// a value cell that looks like a hex / px / rem / shadow.
for (const line of body.split('\n')) {
if (!/\|/.test(line)) continue;
const cells = line.split('|').map((c) => c.trim()).filter((c) => c.length > 0);
if (cells.length < 2) continue;
const [name, value] = cells;
if (!name || !value) continue;
if (/[A-Z]/.test(name) || /\s/.test(name)) continue; // skip header rows / human-prose
const key = `${name}=${value}`;
if (seen.has(key)) continue;
if (!/^[#0-9.a-z(),%\s\-]+$/i.test(value)) continue;
seen.add(key);
out.push({ name, value, kind: classifyKind(name, value) });
}
return out;
}
function classifyKind(name: string, value: string): DesignTokenKind {
if (HEX_RE.test(value) || /^rgb|^hsl/.test(value)) return 'color';
if (/font/i.test(name)) return 'typography';
if (/(radius|rounded)/i.test(name)) return 'radius';
if (/(shadow|elevation)/i.test(name)) return 'shadow';
if (/(space|gap|padding|margin)/i.test(name) || /^(\d+(?:\.\d+)?)(?:px|rem|em)$/.test(value)) return 'spacing';
return 'color';
}
// --- internals -----------------------------------------------------
interface IndexedDesignSystem {
byValue: Map<string, DesignSystemToken[]>; // exact value (case-preserving)
byNormalisedHex: Map<string, DesignSystemToken[]>; // #aabbcc lowercase
byFuzzyName: Map<string, DesignSystemToken[]>; // strip prefix + lowercase
}
function indexDesignSystem(tokens: DesignSystemToken[]): IndexedDesignSystem {
const byValue = new Map<string, DesignSystemToken[]>();
const byNormalisedHex = new Map<string, DesignSystemToken[]>();
const byFuzzyName = new Map<string, DesignSystemToken[]>();
for (const t of tokens) {
push(byValue, t.value, t);
const norm = normaliseHex(t.value);
if (norm) push(byNormalisedHex, norm, t);
push(byFuzzyName, fuzzyName(t.name), t);
}
return { byValue, byNormalisedHex, byFuzzyName };
}
function push<K, V>(map: Map<K, V[]>, key: K, value: V): void {
const arr = map.get(key);
if (arr) arr.push(value); else map.set(key, [value]);
}
function fuzzyName(name: string): string {
return name
.toLowerCase()
.replace(/^-+/, '')
.replace(/^(?:ds-|odds-|theme-)/, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function normaliseHex(value: string): string | null {
const m = HEX_RE.exec(value.trim());
if (!m) return null;
let hex = m[1]!.toLowerCase();
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
if (hex.length === 4) hex = hex.split('').map((c) => c + c).join('');
return `#${hex}`;
}
interface MatchOutcome {
match?: TokenMapMatch;
unmatched: TokenMapUnmatched;
}
function matchOne(
kind: keyof Pick<DesignExtractReport, 'colors' | 'typography' | 'spacing' | 'radius' | 'shadow'>,
entry: DesignTokenEntry,
index: IndexedDesignSystem,
): MatchOutcome {
const tokenKind: DesignTokenKind = kindOf(kind);
const sourceValue = entry.value;
const sourceName = entry.name;
// 1. Exact value match.
const exact = index.byValue.get(sourceValue);
if (exact && exact.length > 0) {
const target = pickKindMatch(exact, tokenKind);
if (target) return wrapMatch(target, sourceValue, sourceName, 'exact', tokenKind, entry.sources);
}
// 2. Normalised hex.
if (tokenKind === 'color') {
const norm = normaliseHex(sourceValue);
if (norm) {
const hits = index.byNormalisedHex.get(norm);
if (hits && hits.length > 0) {
const target = pickKindMatch(hits, tokenKind);
if (target) return wrapMatch(target, sourceValue, sourceName, 'normalised-hex', tokenKind, entry.sources);
}
}
}
// 3. Fuzzy name match (only when source token has a name).
if (sourceName) {
const hits = index.byFuzzyName.get(fuzzyName(sourceName));
if (hits && hits.length > 0) {
const target = pickKindMatch(hits, tokenKind);
if (target) return wrapMatch(target, sourceValue, sourceName, 'name', tokenKind, entry.sources);
}
}
return {
unmatched: {
source: sourceValue,
...(sourceName ? { sourceName } : {}),
kind: tokenKind,
reason: 'no-target-equivalent',
},
};
}
function kindOf(bucket: keyof Pick<DesignExtractReport, 'colors' | 'typography' | 'spacing' | 'radius' | 'shadow'>): DesignTokenKind {
switch (bucket) {
case 'colors': return 'color';
case 'typography': return 'typography';
case 'spacing': return 'spacing';
case 'radius': return 'radius';
case 'shadow': return 'shadow';
}
}
function pickKindMatch(candidates: DesignSystemToken[], kind: DesignTokenKind): DesignSystemToken | undefined {
const sameKind = candidates.find((c) => c.kind === kind);
if (sameKind) return sameKind;
return candidates[0];
}
function wrapMatch(
target: DesignSystemToken,
sourceValue: string,
sourceName: string | undefined,
via: TokenMapMatch['via'],
kind: DesignTokenKind,
sources: string[],
): MatchOutcome {
const match: TokenMapMatch = {
source: sourceValue,
target: target.name,
targetValue: target.value,
via,
kind,
sources: sources.slice(),
} as TokenMapMatch;
if (sourceName) match.sourceName = sourceName;
return {
match,
unmatched: { source: sourceValue, kind, reason: 'no-target-equivalent' },
};
}
async function pathExists(p: string): Promise<boolean> {
try { await fsp.access(p); return true; } catch { return false; }
}
function digestObject(obj: unknown): string {
return createHash('sha1').update(JSON.stringify(obj)).digest('hex');
}

View file

@ -0,0 +1,171 @@
// Phase 4 / spec §23.3.5 — bundled plugin boot walker.
//
// On daemon startup, scan `<repo-root>/plugins/_official/**` for
// folders that look like installable plugin manifests (a SKILL.md
// + open-design.json pair) and register every match into the
// `installed_plugins` table under `source_kind='bundled'` /
// `trust='bundled'`. Bundled plugins are the preinstalled cache of the
// official registry source: they can carry marketplace provenance while
// their bytes stay inside the runtime image for offline first-run use.
// They never enter the user's home install root; their fs_path stays
// inside the repo so a daemon upgrade rotates them in lockstep with the
// daemon code.
//
// `od plugin uninstall` of a bundled plugin is rejected by the
// installer (a future patch); for now, removing the row leaves the
// next boot to re-register, so it's safe.
//
// The walker is idempotent: re-running it updates the manifest_json
// + version column for any folder that changed since last boot,
// matching the spec §23 promise that "bundled plugins replace only on
// daemon upgrade".
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import type Database from 'better-sqlite3';
import {
resolvePluginFolder,
upsertInstalledPlugin,
type RegistryRoots,
} from './registry.js';
import type { InstalledPluginRecord, MarketplaceTrust } from '@open-design/contracts';
type SqliteDb = Database.Database;
export interface RegisterBundledPluginsInput {
db: SqliteDb;
// Absolute path to `<repo-root>/plugins/_official`. The walker
// recurses one level down (`atoms/<atom>`, `scenarios/<scenario>`,
// `bundles/<bundle>`) so the layout matches spec §23.3.5.
bundledRoot: string;
// Optional registry roots override; bundled plugins do not write to
// userPluginsRoot but the installer code path expects one anyway.
roots?: RegistryRoots;
marketplaceProvenance?: {
sourceMarketplaceId: string;
marketplaceTrust: MarketplaceTrust;
entryNamePrefix: string;
};
}
export interface RegisterBundledPluginsResult {
registered: InstalledPluginRecord[];
warnings: string[];
}
const SAFE_BASENAME = /^[a-z0-9][a-z0-9._-]*$/;
export async function registerBundledPlugins(
input: RegisterBundledPluginsInput,
): Promise<RegisterBundledPluginsResult> {
const out: InstalledPluginRecord[] = [];
const warnings: string[] = [];
let topLevel;
try {
topLevel = await fsp.readdir(input.bundledRoot, { withFileTypes: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return { registered: [], warnings: [] };
}
throw err;
}
for (const tier of topLevel) {
if (!tier.isDirectory()) continue;
// Two layouts are supported:
// - plugins/_official/<plugin-id>/ — direct plugin
// - plugins/_official/atoms/<atom>/ — atom subtree
// - plugins/_official/scenarios/<id>/ — scenario subtree
// - plugins/_official/bundles/<id>/ — bundle subtree
// We try the direct shape first, then recurse one level if the
// tier directory itself isn't a manifest folder.
const tierAbs = path.join(input.bundledRoot, tier.name);
const tierManifest = path.join(tierAbs, 'open-design.json');
if (await pathExists(tierManifest)) {
// Direct: <bundledRoot>/<plugin-id>/open-design.json
await registerOne({ folder: tierAbs, folderId: tier.name, out, warnings, input });
continue;
}
let inner;
try {
inner = await fsp.readdir(tierAbs, { withFileTypes: true });
} catch {
continue;
}
for (const entry of inner) {
if (!entry.isDirectory()) continue;
const folder = path.join(tierAbs, entry.name);
const manifest = path.join(folder, 'open-design.json');
if (!(await pathExists(manifest))) continue;
await registerOne({ folder, folderId: entry.name, out, warnings, input });
}
}
return { registered: out, warnings };
}
async function registerOne(args: {
folder: string;
folderId: string;
out: InstalledPluginRecord[];
warnings: string[];
input: RegisterBundledPluginsInput;
}): Promise<void> {
const folderId = args.folderId.toLowerCase();
if (!SAFE_BASENAME.test(folderId)) {
args.warnings.push(`bundled folder name ${args.folderId} is not a safe id; skipped`);
return;
}
const probe = await resolvePluginFolder({
folder: args.folder,
folderId,
sourceKind: 'bundled',
source: args.folder,
// Ship the manifest under trust='bundled' per spec §23.3.4.
// apply.ts coerces this to 'trusted' at apply time so the snapshot
// contract stays binary; the source-of-truth for "this came from
// the daemon image" is the source_kind column.
trust: 'bundled',
});
if (!probe.ok) {
args.warnings.push(`bundled plugin ${args.folderId} failed to parse: ${probe.errors.join('; ')}`);
return;
}
const record = withMarketplaceProvenance(probe.record, args.input.marketplaceProvenance);
upsertInstalledPlugin(args.input.db, record);
args.out.push(record);
}
function withMarketplaceProvenance(
record: InstalledPluginRecord,
provenance: RegisterBundledPluginsInput['marketplaceProvenance'],
): InstalledPluginRecord {
if (!provenance) return record;
return {
...record,
sourceMarketplaceId: provenance.sourceMarketplaceId,
sourceMarketplaceEntryName: `${provenance.entryNamePrefix}/${record.id}`,
sourceMarketplaceEntryVersion: record.version,
marketplaceTrust: provenance.marketplaceTrust,
resolvedSource: record.source,
};
}
async function pathExists(p: string): Promise<boolean> {
try {
await fsp.access(p);
return true;
} catch {
return false;
}
}
// Default bundled root resolution. The daemon ships its `dist/` next to
// the repo, but the canonical bundled location is the repo root's
// `plugins/_official/` directory. We resolve that by walking up from
// the daemon binary's location until we find a `package.json` whose
// name matches the workspace root, or fall back to a sensible default.
export function defaultBundledRoot(workspaceRoot: string): string {
return path.join(workspaceRoot, 'plugins', '_official');
}

View file

@ -0,0 +1,207 @@
// Connector capability gate — spec §9 / §11.3 connector-gate.
//
// Three responsibilities:
//
// (a) Apply path: resolve `od.connectors.required[]` against the
// connector catalog. Populates `connectorsResolved` with status
// (connected / pending / unavailable) and auto-derives an
// `oauth-prompt` GenUI surface (`__auto_connector_<id>`,
// `persist: 'project'`) for any not-yet-connected required
// connector. The plugin author can override the implicit surface
// by declaring a same-id surface explicitly.
//
// (b) Token-issuance path: validate the snapshot's
// `capabilitiesGranted` against `connector:<id>` before the daemon
// hands a connector tool token to the agent. A trusted plugin
// implicitly carries `connector:*`; a restricted plugin must list
// each id explicitly.
//
// (c) /api/tools/connectors/execute re-validates on every call so a
// token replacement attack never bypasses the gate.
//
// All three functions are synchronous and pure relative to their inputs;
// the only side effect is reading the connector status snapshot the
// caller passes in. apply.ts owns the binding into ConnectorService;
// tests inject a stub catalog probe.
import type {
AppliedPluginSnapshot,
GenUISurfaceSpec,
PluginConnectorBinding,
PluginConnectorRef,
PluginManifest,
} from '@open-design/contracts';
export type ConnectorGateStatus = 'connected' | 'pending' | 'unavailable';
export interface ConnectorCatalogEntry {
id: string;
status: ConnectorGateStatus;
accountLabel?: string;
// Subset of tool names that may be invoked. Used to validate
// `od.connectors.required[].tools[]`.
allowedToolNames: string[];
}
export interface ConnectorProbe {
// Sync lookup over the FAST catalog + status maps. Returns undefined
// when the id is unknown — apply.ts surfaces that as a doctor warning.
get(connectorId: string): ConnectorCatalogEntry | undefined;
}
export interface ConnectorRefValidationIssue {
connectorId: string;
code: | 'unknown-connector'
| 'unknown-tool'
| 'missing-capability';
message: string;
// Optional unknown tool list for the unknown-tool issue.
tools?: string[];
}
// Resolve `od.connectors.required[] + optional[]` into apply-time bindings.
// Pure function; the caller injects the catalog probe so tests stay
// deterministic. When the probe returns no entry, the binding's status
// defaults to `unavailable` (the doctor / install path will surface
// 'unknown-connector' separately).
export function resolveConnectorBindings(
manifest: PluginManifest,
probe: ConnectorProbe | undefined,
): { resolved: PluginConnectorBinding[]; required: PluginConnectorRef[] } {
const required: PluginConnectorRef[] = [
...(manifest.od?.connectors?.required ?? []).map((r) => ({ ...r, required: true })),
...(manifest.od?.connectors?.optional ?? []).map((r) => ({ ...r, required: false })),
];
const resolved: PluginConnectorBinding[] = required.map((c) => {
const tools = Array.isArray(c.tools) ? c.tools : [];
if (!probe) {
return { id: c.id, tools, required: c.required, status: 'pending' as const };
}
const entry = probe.get(c.id);
if (!entry) {
return { id: c.id, tools, required: c.required, status: 'unavailable' as const };
}
const binding: PluginConnectorBinding = {
id: entry.id,
tools,
required: c.required,
status: entry.status,
};
if (entry.accountLabel) binding.accountLabel = entry.accountLabel;
return binding;
});
return { resolved, required };
}
// Auto-derive `oauth-prompt` GenUI surfaces for required connectors that
// are not yet connected. Keeps the implicit `__auto_connector_<id>` /
// `persist: 'project'` shape locked by spec §10.3.1. Plugin-declared
// surfaces with the same id win — the apply path filters those out
// before passing into this function (see `mergeAutoOAuthPrompts`).
export function deriveAutoOAuthPrompts(
bindings: PluginConnectorBinding[],
): GenUISurfaceSpec[] {
const out: GenUISurfaceSpec[] = [];
for (const b of bindings) {
if (!b.required) continue;
if (b.status === 'connected') continue;
out.push({
id: `__auto_connector_${b.id}`,
kind: 'oauth-prompt',
persist: 'project',
capabilitiesRequired: [`connector:${b.id}`],
prompt: `This plugin needs the ${b.id} connector. Authorize it to continue.`,
oauth: { route: 'connector', connectorId: b.id },
});
}
return out;
}
// Merge author-declared surfaces with auto-derived ones; same id (case-insensitive)
// wins for the explicit declaration. Returns a deduped list, explicit-first.
export function mergeAutoOAuthPrompts(
declared: GenUISurfaceSpec[],
auto: GenUISurfaceSpec[],
): GenUISurfaceSpec[] {
const ids = new Set(declared.map((s) => s.id.toLowerCase()));
const merged: GenUISurfaceSpec[] = [...declared];
for (const surface of auto) {
if (ids.has(surface.id.toLowerCase())) continue;
merged.push(surface);
ids.add(surface.id.toLowerCase());
}
return merged;
}
// Validate `od.connectors.required[].tools[]` against the catalog.
// Returns issues grouped by connector. Used by `od plugin doctor` (F7).
export function validateConnectorRefs(
manifest: PluginManifest,
probe: ConnectorProbe,
): ConnectorRefValidationIssue[] {
const issues: ConnectorRefValidationIssue[] = [];
const all = [
...(manifest.od?.connectors?.required ?? []),
...(manifest.od?.connectors?.optional ?? []),
];
const required = manifest.od?.connectors?.required ?? [];
const declaredCaps = new Set(manifest.od?.capabilities ?? []);
for (const ref of all) {
const entry = probe.get(ref.id);
if (!entry) {
issues.push({
connectorId: ref.id,
code: 'unknown-connector',
message: `Unknown connector "${ref.id}" — no entry in connectorService.listAll()`,
});
continue;
}
const tools = Array.isArray(ref.tools) ? ref.tools : [];
const allowed = new Set(entry.allowedToolNames);
const unknown = tools.filter((t) => !allowed.has(t));
if (unknown.length > 0) {
issues.push({
connectorId: ref.id,
code: 'unknown-tool',
message: `Connector "${ref.id}" tools not in allowedToolNames: ${unknown.join(', ')}`,
tools: unknown,
});
}
}
for (const ref of required) {
const cap = `connector:${ref.id}`;
if (!declaredCaps.has(cap)) {
issues.push({
connectorId: ref.id,
code: 'missing-capability',
message: `Required connector "${ref.id}" is missing the "${cap}" capability declaration`,
});
}
}
return issues;
}
// Token-issuance gate. Called from `apps/daemon/src/tool-tokens.ts`
// before the daemon hands a connector tool token to the agent, and from
// `/api/tools/connectors/execute` on every call (defense in depth, c).
//
// Returns `{ ok: true }` when the snapshot is allowed to use the
// connector. Returns `{ ok: false, reason }` otherwise; callers MUST
// reject the request with HTTP 403.
export function checkConnectorTokenIssuance(args: {
snapshot: Pick<AppliedPluginSnapshot, 'capabilitiesGranted'>;
trust: 'trusted' | 'restricted' | 'bundled';
connectorId: string;
}): { ok: true } | { ok: false; reason: string } {
const cap = `connector:${args.connectorId}`;
const granted = new Set(args.snapshot.capabilitiesGranted);
if (args.trust !== 'restricted') {
// Trusted + bundled implicitly carry connector:*.
return { ok: true };
}
if (granted.has(cap)) return { ok: true };
return {
ok: false,
reason: `restricted plugin lacks "${cap}" — Grant the capability via /api/plugins/:id/trust before issuing a token`,
};
}

View file

@ -0,0 +1,195 @@
// Phase 4 / spec §11.5 / plan §3.AA1 — plugin diff helper.
//
// Pure helper that compares two InstalledPluginRecord values (or
// two AppliedPluginSnapshot values) and returns a structured
// report of every field that changed. The author dev-loop needs
// this when:
//
// - debugging replay invariance ('why does my snapshot's prompt
// block differ from the previous run?'),
// - reviewing a github-installed plugin's bump ('what changed in
// v0.2.0 vs. v0.1.5?'),
// - inspecting a fork ('how does my edit deviate from upstream?').
//
// The report is intentionally shallow per field so the CLI's
// renderer can show a useful one-line summary without dragging in
// a generic diff library. Deep object diffs (e.g. context.skills[]
// content) collapse to a single 'changed' summary with the count
// of additions / removals.
import type { InstalledPluginRecord, PluginManifest } from '@open-design/contracts';
export interface PluginDiffEntry {
field: string;
// 'added' = present in b but not a; 'removed' = inverse;
// 'changed' = both sides have it but values differ.
kind: 'added' | 'removed' | 'changed';
// Stringified for display. CLI renders as `<a> -> <b>`.
before?: string;
after?: string;
// Optional human hint for collection-shaped fields (e.g.
// 'inputs[]: 2 added, 1 removed').
summary?: string;
}
export interface PluginDiffReport {
// Same id when both sides share an id; otherwise the diff
// surfaces the rename via the entries.
pluginId?: string;
// Stable, sorted by field path so re-runs produce byte-equal
// output (modulo timestamps).
entries: PluginDiffEntry[];
// Aggregate count for at-a-glance audit.
added: number;
removed: number;
changed: number;
}
export interface DiffPluginsInput {
a: InstalledPluginRecord;
b: InstalledPluginRecord;
}
export function diffPlugins(input: DiffPluginsInput): PluginDiffReport {
const out: PluginDiffEntry[] = [];
// Top-level record fields that matter to the user.
diffScalar(out, 'id', input.a.id, input.b.id);
diffScalar(out, 'title', input.a.title, input.b.title);
diffScalar(out, 'version', input.a.version, input.b.version);
diffScalar(out, 'sourceKind', input.a.sourceKind, input.b.sourceKind);
diffScalar(out, 'source', input.a.source, input.b.source);
diffScalar(out, 'trust', input.a.trust, input.b.trust);
diffArray(out, 'capabilitiesGranted', input.a.capabilitiesGranted, input.b.capabilitiesGranted);
// Manifest body — the field set the prompt + apply paths read.
diffManifest(out, input.a.manifest, input.b.manifest);
out.sort((a, b) => a.field.localeCompare(b.field));
let added = 0; let removed = 0; let changed = 0;
for (const e of out) {
if (e.kind === 'added') added++;
if (e.kind === 'removed') removed++;
if (e.kind === 'changed') changed++;
}
const report: PluginDiffReport = { entries: out, added, removed, changed };
if (input.a.id === input.b.id) report.pluginId = input.a.id;
return report;
}
function diffManifest(out: PluginDiffEntry[], a: PluginManifest, b: PluginManifest): void {
diffScalar(out, 'manifest.title', a.title, b.title);
diffScalar(out, 'manifest.version', a.version, b.version);
diffScalar(out, 'manifest.description', a.description, b.description);
diffScalar(out, 'manifest.license', a.license, b.license);
diffArray (out, 'manifest.tags', a.tags ?? [], b.tags ?? []);
diffScalar(out, 'od.kind', a.od?.kind, b.od?.kind);
diffScalar(out, 'od.taskKind', a.od?.taskKind, b.od?.taskKind);
diffScalar(out, 'od.mode', a.od?.mode, b.od?.mode);
diffArray (out, 'od.capabilities', a.od?.capabilities ?? [], b.od?.capabilities ?? []);
diffArray (out, 'od.inputs[]',
(a.od?.inputs ?? []).map((i) => i?.name).filter(Boolean) as string[],
(b.od?.inputs ?? []).map((i) => i?.name).filter(Boolean) as string[]);
diffArray (out, 'od.context.skills',
(a.od?.context?.skills ?? []).map((s) => s?.ref ?? s?.path ?? '').filter(Boolean),
(b.od?.context?.skills ?? []).map((s) => s?.ref ?? s?.path ?? '').filter(Boolean));
diffArray (out, 'od.context.craft',
(a.od?.context?.craft ?? []).slice() as string[],
(b.od?.context?.craft ?? []).slice() as string[]);
diffArray (out, 'od.context.assets',
(a.od?.context?.assets ?? []).slice() as string[],
(b.od?.context?.assets ?? []).slice() as string[]);
diffPipeline(out, a.od?.pipeline, b.od?.pipeline);
diffArray (out, 'od.connectors.required',
(a.od?.connectors?.required ?? []).map((c) => c?.id ?? '').filter(Boolean),
(b.od?.connectors?.required ?? []).map((c) => c?.id ?? '').filter(Boolean));
diffArray (out, 'od.genui.surfaces',
(a.od?.genui?.surfaces ?? []).map((s) => s?.id ?? '').filter(Boolean),
(b.od?.genui?.surfaces ?? []).map((s) => s?.id ?? '').filter(Boolean));
}
function diffPipeline(
out: PluginDiffEntry[],
a: PluginManifest['od'] extends infer T ? T extends { pipeline?: infer P } ? Exclude<P, undefined> | undefined : never : never,
b: PluginManifest['od'] extends infer T ? T extends { pipeline?: infer P } ? Exclude<P, undefined> | undefined : never : never,
): void {
if (!a && !b) return;
if (!a && b) {
out.push({ field: 'od.pipeline', kind: 'added',
after: stagesSummary(b!.stages) });
return;
}
if (a && !b) {
out.push({ field: 'od.pipeline', kind: 'removed',
before: stagesSummary(a.stages) });
return;
}
diffArray(out, 'od.pipeline.stages',
(a!.stages ?? []).map((s) => s.id),
(b!.stages ?? []).map((s) => s.id));
// Per-stage atoms diff. Key by stage id; stages added or removed
// surface above already.
const aById = new Map((a!.stages ?? []).map((s) => [s.id, s] as const));
const bById = new Map((b!.stages ?? []).map((s) => [s.id, s] as const));
for (const id of new Set([...aById.keys(), ...bById.keys()])) {
const sa = aById.get(id);
const sb = bById.get(id);
if (!sa || !sb) continue; // covered by stages-array diff above
diffArray(out, `od.pipeline.stages[${id}].atoms`,
sa.atoms ?? [], sb.atoms ?? []);
diffScalar(out, `od.pipeline.stages[${id}].until`, sa.until, sb.until);
diffScalar(out, `od.pipeline.stages[${id}].repeat`,
sa.repeat === undefined ? undefined : String(sa.repeat),
sb.repeat === undefined ? undefined : String(sb.repeat));
}
}
function stagesSummary(stages: ReadonlyArray<{ id: string }> | undefined): string {
if (!stages || stages.length === 0) return '<empty>';
return stages.map((s) => s.id).join(' \u2192 ');
}
function diffScalar(out: PluginDiffEntry[], field: string, a: unknown, b: unknown): void {
const aPresent = a !== undefined && a !== null;
const bPresent = b !== undefined && b !== null;
if (!aPresent && !bPresent) return;
if (!aPresent && bPresent) { out.push({ field, kind: 'added', after: String(b) }); return; }
if (aPresent && !bPresent) { out.push({ field, kind: 'removed', before: String(a) }); return; }
// Both present.
if (toComparable(a) === toComparable(b)) return;
out.push({ field, kind: 'changed', before: String(a), after: String(b) });
}
function diffArray(out: PluginDiffEntry[], field: string, a: ReadonlyArray<string>, b: ReadonlyArray<string>): void {
const setA = new Set(a);
const setB = new Set(b);
const added = [...setB].filter((x) => !setA.has(x));
const removed = [...setA].filter((x) => !setB.has(x));
if (added.length === 0 && removed.length === 0) {
// Detect order changes that aren't set-changes.
if (a.length === b.length && a.every((v, i) => v === b[i])) return;
out.push({ field, kind: 'changed',
before: a.join(','),
after: b.join(','),
summary: `reordered (${a.length} entries)`,
});
return;
}
out.push({
field,
kind: 'changed',
summary: `${added.length} added, ${removed.length} removed`,
before: removed.join(','),
after: added.join(','),
});
}
function toComparable(value: unknown): string {
if (value === undefined || value === null) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number') return String(value);
if (typeof value === 'boolean') return value ? '1' : '0';
try { return JSON.stringify(value); } catch { return String(value); }
}

View file

@ -0,0 +1,212 @@
// Plugin doctor. Surfaces pre-apply lint diagnostics:
//
// - Validates the manifest using @open-design/plugin-runtime's validateSafe.
// - Cross-checks atom ids against the FIRST_PARTY_ATOMS catalog (warns on
// planned atoms, errors on unknown atoms).
// - Re-resolves context against the live registry and reports any refs that
// the registry could not bind (skills/design-systems/craft).
// - Compares the registry-cached manifestSourceDigest against a freshly
// computed digest and flips snapshots to `stale` when the upstream plugin
// changed under their feet.
//
// Phase 1 returns a flat list of issues rather than a JSON-structured report;
// the CLI renders them as `od plugin doctor <id>` output. Spec §11.5 promises
// a richer report (severity / kind enum) which we'll layer in once Phase 4
// adds the diagnostics endpoint.
import { manifestSourceDigest, resolveContext, validateSafe, type RegistryView } from '@open-design/plugin-runtime';
import type { InstalledPluginRecord, PluginManifest } from '@open-design/contracts';
import type Database from 'better-sqlite3';
import { findAtom, isImplementedAtom, isKnownAtom } from './atoms.js';
import { validateConnectorRefs, type ConnectorProbe } from './connector-gate.js';
import { isParseableUntil } from './until.js';
import { listSnapshotsForProject, markSnapshotStale } from './snapshots.js';
type SqliteDb = Database.Database;
export type DiagnosticSeverity = 'error' | 'warning' | 'info';
export interface Diagnostic {
severity: DiagnosticSeverity;
code: string;
message: string;
field?: string;
}
export interface DoctorReport {
pluginId: string;
ok: boolean;
issues: Diagnostic[];
freshDigest: string;
}
export function doctorPlugin(
plugin: InstalledPluginRecord,
registry: RegistryView,
options?: {
warnOnMissingRefs?: boolean;
connectorProbe?: ConnectorProbe | undefined;
},
): DoctorReport {
const issues: Diagnostic[] = [];
const manifest = plugin.manifest;
const validation = validateSafe(manifest);
for (const err of validation.errors) {
issues.push({ severity: 'error', code: 'manifest.invalid', message: err });
}
for (const warn of validation.warnings) {
issues.push({ severity: 'warning', code: 'manifest.warning', message: warn });
}
for (const atomId of manifest.od?.context?.atoms ?? []) {
if (!isKnownAtom(atomId)) {
issues.push({
severity: 'error',
code: 'atom.unknown',
message: `Unknown atom id: '${atomId}'.`,
field: 'od.context.atoms',
});
} else if (!isImplementedAtom(atomId)) {
const atom = findAtom(atomId);
issues.push({
severity: 'warning',
code: 'atom.planned',
message: `Atom '${atomId}'${atom ? ` (${atom.label})` : ''} is planned but not yet implemented; runs will skip this atom.`,
field: 'od.context.atoms',
});
}
}
for (const stage of manifest.od?.pipeline?.stages ?? []) {
for (const atomId of stage.atoms ?? []) {
if (!isKnownAtom(atomId)) {
issues.push({
severity: 'error',
code: 'atom.unknown',
message: `Pipeline stage '${stage.id}' references unknown atom '${atomId}'.`,
field: `od.pipeline.stages.${stage.id}`,
});
}
}
if (stage.repeat === true && !stage.until) {
issues.push({
severity: 'error',
code: 'pipeline.until-missing',
message: `Pipeline stage '${stage.id}' sets repeat:true but no until expression.`,
field: `od.pipeline.stages.${stage.id}`,
});
}
if (stage.until && !isParseableUntil(stage.until)) {
issues.push({
severity: 'error',
code: 'pipeline.until-invalid',
message: `Pipeline stage '${stage.id}' has an unparseable until expression: '${stage.until}'.`,
field: `od.pipeline.stages.${stage.id}`,
});
}
}
if (options?.connectorProbe) {
for (const issue of validateConnectorRefs(manifest, options.connectorProbe)) {
issues.push({
severity: issue.code === 'unknown-connector' ? 'error' : 'warning',
code: `connector.${issue.code}`,
message: issue.message,
field: 'od.connectors',
});
}
}
// Plan §3.K3 / spec §10.3.5 — surface.component capability gate.
// A plugin that ships a custom React component must declare the
// `genui:custom-component` capability so the trust gate at apply
// time can refuse it for restricted installs.
for (const surface of manifest.od?.genui?.surfaces ?? []) {
if (!surface.component) continue;
const declared = new Set(manifest.od?.capabilities ?? []);
if (!declared.has('genui:custom-component')) {
issues.push({
severity: 'error',
code: 'genui.component-capability',
message: `Surface '${surface.id}' ships a component but the manifest does not declare the 'genui:custom-component' capability.`,
field: 'od.genui.surfaces',
});
}
if (surface.component.path.includes('..')) {
issues.push({
severity: 'error',
code: 'genui.component-traversal',
message: `Surface '${surface.id}' component path must be relative without traversal segments.`,
field: 'od.genui.surfaces',
});
}
}
const resolved = resolveContext(manifest, {
registry,
warnOnMissing: options?.warnOnMissingRefs ?? true,
});
for (const warn of resolved.warnings) {
issues.push({ severity: 'warning', code: 'context.unresolved', message: warn });
}
const freshDigest = manifestSourceDigest({
manifest,
inputs: {},
resolvedContextRefs: resolved.digestRefs,
});
if (plugin.sourceDigest && plugin.sourceDigest !== freshDigest) {
issues.push({
severity: 'warning',
code: 'digest.drift',
message: `Cached source digest '${plugin.sourceDigest.slice(0, 12)}…' differs from fresh '${freshDigest.slice(0, 12)}…'. Existing snapshots may be marked stale.`,
});
}
const ok = issues.every((d) => d.severity !== 'error');
return { pluginId: plugin.id, ok, issues, freshDigest };
}
// Walk every snapshot for a project and flip those whose digest no longer
// matches the live plugin's freshly computed digest. Called by `od plugin
// doctor --project <id>` and the apply path when a plugin upgrade is
// detected. Returns the list of snapshot ids that were re-tagged.
export function markStaleSnapshotsForProject(
db: SqliteDb,
projectId: string,
resolveDigest: (snapshot: { pluginId: string; manifestSourceDigest: string }) => string | null,
): string[] {
const updated: string[] = [];
const snapshots = listSnapshotsForProject(db, projectId);
for (const snap of snapshots) {
if (snap.status !== 'fresh') continue;
const fresh = resolveDigest({ pluginId: snap.pluginId, manifestSourceDigest: snap.manifestSourceDigest });
if (fresh && fresh !== snap.manifestSourceDigest) {
markSnapshotStale(db, snap.snapshotId);
updated.push(snap.snapshotId);
}
}
return updated;
}
export function summarizeDoctor(report: DoctorReport): string {
const errs = report.issues.filter((d) => d.severity === 'error');
const warns = report.issues.filter((d) => d.severity === 'warning');
if (errs.length === 0 && warns.length === 0) {
return `Plugin '${report.pluginId}' is OK (digest ${report.freshDigest.slice(0, 12)}…).`;
}
const parts: string[] = [];
parts.push(`Plugin '${report.pluginId}': ${errs.length} error(s), ${warns.length} warning(s).`);
for (const issue of report.issues) {
parts.push(` [${issue.severity}] ${issue.code}: ${issue.message}`);
}
return parts.join('\n');
}
export function buildRegistryViewFromManifest(_manifest: PluginManifest, fallback: RegistryView): RegistryView {
// Phase 1 stub — returns the fallback unchanged. Phase 2A will overlay
// bundled-plugin-specific catalogs when the plugin ships its own skills /
// design systems within `<plugin>/skills/` etc.
return fallback;
}

View file

@ -0,0 +1,194 @@
// Phase 4 / spec §11.5 / plan §3.II1 — plugin event ring buffer.
//
// In-memory FIFO ring buffer of plugin lifecycle events
// (install / uninstall / upgrade / apply / snapshot-prune).
// Capped at MAX_BUFFER entries to keep daemon memory bounded
// even on a long-running install spree. Older entries fall off
// the head when the buffer is full.
//
// The buffer is read-only from outside this module. Producers
// (installer / uninstaller / apply path) call `recordPluginEvent()`;
// consumers subscribe via `subscribe()` (returns an unsubscribe
// callback) or pull `snapshot()` for a one-shot read. The route
// in server.ts wires both into a single SSE endpoint.
//
// No SQLite, no FS — pure in-memory state. A daemon restart
// resets the buffer (events survive the run, not the restart).
export type PluginEventKind =
| 'plugin.installed'
| 'plugin.upgraded'
| 'plugin.uninstalled'
| 'plugin.trust-changed'
| 'plugin.applied'
| 'plugin.snapshot-pruned'
| 'plugin.marketplace-refreshed';
export interface PluginEvent {
// Unique-per-buffer monotonically-increasing id. Resets on
// daemon restart. Lets a CLI consumer ask 'what's new since
// event #N?' without re-reading the whole buffer.
id: number;
kind: PluginEventKind;
// Epoch ms.
at: number;
// The plugin id this event relates to. Some events
// (marketplace-refreshed) have no plugin id; they pass
// pluginId='' so consumers can filter consistently.
pluginId: string;
// Optional structured payload — installer ships
// { source, version }, uninstaller ships { reason }, etc.
details?: Record<string, unknown>;
}
const MAX_BUFFER = 1000;
interface Subscriber {
(event: PluginEvent): void;
}
class PluginEventBuffer {
private buffer: PluginEvent[] = [];
private subscribers = new Set<Subscriber>();
private nextId = 1;
record(input: Omit<PluginEvent, 'id' | 'at'>): PluginEvent {
const event: PluginEvent = {
id: this.nextId++,
at: Date.now(),
kind: input.kind,
pluginId: input.pluginId,
...(input.details ? { details: input.details } : {}),
};
this.buffer.push(event);
if (this.buffer.length > MAX_BUFFER) {
this.buffer = this.buffer.slice(this.buffer.length - MAX_BUFFER);
}
// Fan out to subscribers; exceptions are swallowed so a
// misbehaving listener can't poison the buffer.
for (const sub of this.subscribers) {
try { sub(event); } catch { /* ignore */ }
}
return event;
}
// Returns a copy of the current buffer slice (since `since`
// exclusive). Pass since=0 (or omit) for the whole buffer.
snapshot(since = 0): PluginEvent[] {
if (since <= 0) return this.buffer.slice();
return this.buffer.filter((e) => e.id > since);
}
// Subscribe to live events. Returns an unsubscribe callback.
subscribe(fn: Subscriber): () => void {
this.subscribers.add(fn);
return () => { this.subscribers.delete(fn); };
}
// Test-only reset. Production callers never invoke this.
reset(): void {
this.buffer = [];
this.subscribers.clear();
this.nextId = 1;
}
size(): number { return this.buffer.length; }
}
const singleton = new PluginEventBuffer();
export function recordPluginEvent(input: Omit<PluginEvent, 'id' | 'at'>): PluginEvent {
return singleton.record(input);
}
export function pluginEventSnapshot(since?: number): PluginEvent[] {
return singleton.snapshot(since);
}
export function subscribePluginEvents(fn: Subscriber): () => void {
return singleton.subscribe(fn);
}
export function pluginEventBufferSize(): number {
return singleton.size();
}
// Test-only helper for vitest (the production path never calls
// this). Exported so vitest can clear state between cases.
export function __resetPluginEventBufferForTests(): void {
singleton.reset();
}
// Plan §3.NN1 — operator-facing buffer reset. Distinct from
// __resetPluginEventBufferForTests because it returns the
// pre-purge stats so the caller can audit what was discarded.
// Exposed via the loopback-only `POST /api/plugins/events/purge`
// route + `od plugin events purge` CLI subcommand.
export interface PurgePluginEventBufferResult {
purged: number;
// The ids of the first / last entry that was discarded, so an
// operator who exported the buffer right before the purge can
// confirm coverage.
firstId: number | null;
lastId: number | null;
// The ringe buffer's nextId value PRE-purge — useful for
// debugging 'did we lose a window of events between export
// and purge?'.
preNextId: number;
}
export function purgePluginEventBuffer(): PurgePluginEventBufferResult {
const events = singleton.snapshot();
const result: PurgePluginEventBufferResult = {
purged: events.length,
firstId: events.length > 0 ? events[0]!.id : null,
lastId: events.length > 0 ? events[events.length - 1]!.id : null,
preNextId: (singleton as unknown as { nextId: number }).nextId,
};
singleton.reset();
return result;
}
// Plan §3.KK2 — pure roll-up over a slice of events. Useful for
// dashboards + the `od plugin events stats` CLI summary. Lives
// next to the buffer so consumers can compute the same rollup
// shape over either the full buffer or a filtered subset.
export interface PluginEventStats {
total: number;
byKind: Record<string, number>;
byPluginId: Record<string, number>;
oldestAt: number | null;
newestAt: number | null;
// Ids of the first / last entries so a CLI can echo the range
// without re-walking the slice.
firstId: number | null;
lastId: number | null;
}
export function summarisePluginEvents(events: ReadonlyArray<PluginEvent>): PluginEventStats {
const stats: PluginEventStats = {
total: events.length,
byKind: {},
byPluginId: {},
oldestAt: null,
newestAt: null,
firstId: null,
lastId: null,
};
for (const ev of events) {
stats.byKind[ev.kind] = (stats.byKind[ev.kind] ?? 0) + 1;
if (ev.pluginId) {
stats.byPluginId[ev.pluginId] = (stats.byPluginId[ev.pluginId] ?? 0) + 1;
}
if (typeof ev.at === 'number') {
stats.oldestAt = stats.oldestAt === null ? ev.at : Math.min(stats.oldestAt, ev.at);
stats.newestAt = stats.newestAt === null ? ev.at : Math.max(stats.newestAt, ev.at);
}
if (typeof ev.id === 'number') {
stats.firstId = stats.firstId === null ? ev.id : Math.min(stats.firstId, ev.id);
stats.lastId = stats.lastId === null ? ev.id : Math.max(stats.lastId, ev.id);
}
}
return stats;
}

View file

@ -0,0 +1,204 @@
// Phase 4 / spec §14 — `od plugin export <projectId> --as <target>`.
//
// Materialises a publish-ready folder from the AppliedPluginSnapshot
// the project was created against. The exporter does NOT modify the
// source plugin; it freezes the snapshot's view (manifest source
// digest, inputs, resolved context) into a new directory the author
// can re-publish to anthropics/skills, awesome-agent-skills, clawhub,
// or skills.sh. Three targets:
//
// - `od` → SKILL.md + open-design.json (canonical OD shape).
// - `claude-plugin` → SKILL.md + .claude-plugin/plugin.json (Claude
// Code listing format).
// - `agent-skill` → SKILL.md only (every catalog accepts this).
//
// The export is best-effort: it pulls SKILL.md straight off the
// installed plugin's fs_path, and reconstructs open-design.json from
// the cached `manifest_json` so a publishable snapshot is reproducible
// even after an `od plugin update` rotates the live source.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import type Database from 'better-sqlite3';
import type { AppliedPluginSnapshot } from '@open-design/contracts';
import { getInstalledPlugin } from './registry.js';
import { getSnapshot } from './snapshots.js';
type SqliteDb = Database.Database;
export type ExportTarget = 'od' | 'claude-plugin' | 'agent-skill';
export interface ExportInput {
db: SqliteDb;
// Either pass a snapshot id directly (recommended; lets a code agent
// export the exact view that produced a particular run), or pass a
// project id and the most-recent snapshot row is used.
snapshotId?: string;
projectId?: string;
target: ExportTarget;
outDir: string;
}
export interface ExportResult {
folder: string;
files: string[];
snapshotId: string;
}
export class ExportError extends Error {
constructor(message: string) {
super(message);
this.name = 'ExportError';
}
}
export async function exportPlugin(input: ExportInput): Promise<ExportResult> {
const snapshot = pickSnapshot(input);
if (!snapshot) {
throw new ExportError(
input.snapshotId
? `snapshot ${input.snapshotId} not found`
: `no snapshot found for project ${input.projectId}`,
);
}
const plugin = getInstalledPlugin(input.db, snapshot.pluginId);
// It's legal to export a snapshot whose plugin has since been
// uninstalled — we fall back to the snapshot's frozen manifest
// metadata. The .fs_path / SKILL.md copy is best-effort in that
// case (skip on miss).
const folder = path.join(input.outDir, snapshot.pluginId);
await fsp.mkdir(folder, { recursive: true });
const written: string[] = [];
// SKILL.md — copy from the installed plugin if available, otherwise
// synthesize from the snapshot's plugin title + description.
const skillBody = await readSkillBody(plugin?.fsPath, snapshot);
if (input.target !== 'od') {
const skillPath = path.join(folder, 'SKILL.md');
await fsp.writeFile(skillPath, skillBody, 'utf8');
written.push(skillPath);
} else {
// 'od' target: still ship SKILL.md as the portable anchor (per
// spec §3 the canonical floor).
const skillPath = path.join(folder, 'SKILL.md');
await fsp.writeFile(skillPath, skillBody, 'utf8');
written.push(skillPath);
}
if (input.target === 'od') {
const manifest = buildPortableManifest(snapshot);
const manifestPath = path.join(folder, 'open-design.json');
await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
written.push(manifestPath);
}
if (input.target === 'claude-plugin') {
const cpDir = path.join(folder, '.claude-plugin');
await fsp.mkdir(cpDir, { recursive: true });
const cp = {
name: snapshot.pluginId,
description: snapshot.pluginDescription ?? '',
version: snapshot.pluginVersion,
};
const cpPath = path.join(cpDir, 'plugin.json');
await fsp.writeFile(cpPath, JSON.stringify(cp, null, 2) + '\n', 'utf8');
written.push(cpPath);
}
// Always ship a small README that records the source snapshot id
// and digest. This is the audit trail for "which version of the
// plugin produced this folder" — exactly the contract spec §8.2.1
// expects from a replay.
const readme = [
`# ${snapshot.pluginTitle ?? snapshot.pluginId}`,
'',
snapshot.pluginDescription ?? '',
'',
'## Provenance',
'',
`- Snapshot id: \`${snapshot.snapshotId}\``,
`- Plugin version: \`${snapshot.pluginVersion}\``,
`- Manifest digest: \`${snapshot.manifestSourceDigest}\``,
`- Task kind: \`${snapshot.taskKind}\``,
'',
'This folder was produced by `od plugin export`.',
'',
].join('\n');
const readmePath = path.join(folder, 'README.md');
await fsp.writeFile(readmePath, readme, 'utf8');
written.push(readmePath);
return { folder, files: written, snapshotId: snapshot.snapshotId };
}
function pickSnapshot(input: ExportInput): AppliedPluginSnapshot | null {
if (input.snapshotId) {
return getSnapshot(input.db, input.snapshotId);
}
if (input.projectId) {
const row = input.db
.prepare(`SELECT id FROM applied_plugin_snapshots WHERE project_id = ? ORDER BY applied_at DESC LIMIT 1`)
.get(input.projectId) as { id?: string } | undefined;
if (!row?.id) return null;
return getSnapshot(input.db, row.id);
}
return null;
}
async function readSkillBody(
fsPath: string | undefined,
snapshot: AppliedPluginSnapshot,
): Promise<string> {
if (fsPath) {
try {
return await fsp.readFile(path.join(fsPath, 'SKILL.md'), 'utf8');
} catch {
// fall through to synthesis
}
}
return [
'---',
`name: ${snapshot.pluginId}`,
`description: ${snapshot.pluginDescription ?? snapshot.pluginTitle ?? snapshot.pluginId}`,
`od:`,
` scenario: general`,
'---',
'',
`# ${snapshot.pluginTitle ?? snapshot.pluginId}`,
'',
snapshot.pluginDescription ?? '',
'',
`Snapshot id: ${snapshot.snapshotId}`,
`Manifest digest: ${snapshot.manifestSourceDigest}`,
'',
].join('\n');
}
function buildPortableManifest(snapshot: AppliedPluginSnapshot): Record<string, unknown> {
return {
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
specVersion: snapshot.pluginSpecVersion ?? '1.0.0',
name: snapshot.pluginId,
title: snapshot.pluginTitle ?? snapshot.pluginId,
version: snapshot.pluginVersion,
description: snapshot.pluginDescription ?? '',
license: 'MIT',
od: {
kind: 'skill',
taskKind: snapshot.taskKind,
...(snapshot.query ? { useCase: { query: snapshot.query } } : {}),
context: {
...(snapshot.resolvedContext.items.length > 0
? { atoms: snapshot.resolvedContext.items.filter((i) => i.kind === 'atom').map((i) => (i as { id: string }).id) }
: {}),
},
capabilities: snapshot.capabilitiesGranted,
...(snapshot.pipeline ? { pipeline: snapshot.pipeline } : {}),
},
provenance: {
snapshotId: snapshot.snapshotId,
manifestSourceDigest: snapshot.manifestSourceDigest,
appliedAt: snapshot.appliedAt,
},
};
}

View file

@ -0,0 +1,104 @@
// Snapshot GC worker (plan §3.A5 / spec §16 Phase 5 / PB2).
//
// The unreferenced-snapshot TTL is enforced by `pruneExpiredSnapshots()`
// (defined in `snapshots.ts`); this module owns the periodic schedule
// and the audit log. The worker is started from `server.ts` boot and
// disabled when `OD_SNAPSHOT_GC_INTERVAL_MS` is set to `0`.
//
// Operator escape hatch: `od plugin snapshots prune --before <ts>` calls
// `pruneExpiredSnapshots(db, { before: cutoff })` synchronously without
// touching the periodic timer.
import type Database from 'better-sqlite3';
import { pruneExpiredSnapshots, type PruneExpiredResult } from './snapshots.js';
import { readPluginEnvKnobs } from '../app-config.js';
type SqliteDb = Database.Database;
export interface SnapshotGcOptions {
db: SqliteDb;
// Override the timer interval. When undefined we read
// `readPluginEnvKnobs().snapshotGcIntervalMs`. Setting `0` disables.
intervalMs?: number;
// Sink for audit messages. Defaults to console.log so packaged
// deployments capture it on stdout.
logger?: (msg: string, meta?: Record<string, unknown>) => void;
// Optional hook invoked after each tick so tests can synchronize on
// sweep completion.
onTick?: (result: PruneExpiredResult) => void;
}
export interface SnapshotGcHandle {
stop(): void;
// Force-runs a sweep synchronously. Useful for the CLI escape hatch
// and for tests that don't want to sleep through the timer.
sweep(now?: number): PruneExpiredResult;
}
const NOOP_HANDLE: SnapshotGcHandle = {
stop: () => undefined,
sweep: () => ({ removed: 0, ids: [] }),
};
export function startSnapshotGc(opts: SnapshotGcOptions): SnapshotGcHandle {
const knobs = readPluginEnvKnobs();
const intervalMs = opts.intervalMs ?? knobs.snapshotGcIntervalMs;
const log = opts.logger ?? defaultLogger;
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
log('[plugins] snapshot GC disabled (interval ms <= 0)');
return NOOP_HANDLE;
}
const tick = () => {
try {
// Plan §3.M1 / spec PB2 — feed OD_SNAPSHOT_RETENTION_DAYS into
// pruneExpiredSnapshots so referenced rows whose project has
// been deleted are eligible for deletion after the configured
// window. The unreferenced-TTL sweep stays the v1 default
// path; retention only kicks in when the operator opted in.
const knobs = readPluginEnvKnobs();
const result = pruneExpiredSnapshots(opts.db, {
...(typeof knobs.snapshotRetentionDays === 'number' && knobs.snapshotRetentionDays > 0
? { retentionDays: knobs.snapshotRetentionDays }
: {}),
});
if (result.removed > 0) {
log(`[plugins] snapshot GC removed ${result.removed} expired snapshot(s)`, {
ids: result.ids,
});
}
opts.onTick?.(result);
} catch (err) {
log(`[plugins] snapshot GC tick failed: ${(err as Error).message ?? String(err)}`);
}
};
const timer = setInterval(tick, intervalMs);
timer.unref?.();
return {
stop: () => {
clearInterval(timer);
},
sweep: (now?: number) => {
const knobs = readPluginEnvKnobs();
return pruneExpiredSnapshots(opts.db, {
...(now ? { now } : {}),
...(typeof knobs.snapshotRetentionDays === 'number' && knobs.snapshotRetentionDays > 0
? { retentionDays: knobs.snapshotRetentionDays }
: {}),
});
},
};
}
function defaultLogger(msg: string, meta?: Record<string, unknown>): void {
if (meta) {
// eslint-disable-next-line no-console
console.log(msg, meta);
} else {
// eslint-disable-next-line no-console
console.log(msg);
}
}

View file

@ -0,0 +1,103 @@
// Daemon plugin module barrel. Re-exports the surface that server.ts and
// cli.ts need so the rest of the daemon never reaches into individual files
// and accidentally bypasses the snapshot writer (spec §8.2.1).
export * from './atoms.js';
export * from './apply.js';
export {
validatePluginFolder,
flattenValidationDiagnostics,
type ValidatePluginFolderInput,
type ValidatePluginFolderResult,
} from './validate.js';
export {
packPlugin,
PackPluginError,
type PackPluginInput,
type PackPluginResult,
} from './pack.js';
export {
searchInstalledPlugins,
type SearchInstalledPluginsInput,
type SearchInstalledPluginsResult,
type SearchInstalledPluginsResultEntry,
} from './search.js';
export {
diffPlugins,
type DiffPluginsInput,
type PluginDiffReport,
type PluginDiffEntry,
} from './diff.js';
export {
diffSnapshots,
type DiffSnapshotsInput,
type SnapshotDiffReport,
type SnapshotDiffEntry,
} from './snapshot-diff.js';
export {
pluginInventoryStats,
pluginSourceBuckets,
snapshotInventoryStats,
type PluginInventoryStats,
type PluginSourceBucket,
type PluginSourceBucketsResult,
type SnapshotInventoryStats,
type SnapshotStatsRow,
} from './stats.js';
export {
simulatePipeline,
parseSignalKv,
type SimulatePipelineInput,
type SimulatePipelineResult,
type SimulateStageOutcome,
type StageSignalProvider,
} from './simulate.js';
export {
verifyPlugin,
type VerifyConfig,
type VerifyInput,
type VerifyReport,
type VerifyCheckOutcome,
type VerifyCheckId,
} from './verify.js';
export {
recordPluginEvent,
pluginEventSnapshot,
subscribePluginEvents,
pluginEventBufferSize,
summarisePluginEvents,
purgePluginEventBuffer,
type PluginEvent,
type PluginEventKind,
type PluginEventStats,
type PurgePluginEventBufferResult,
} from './events.js';
export * from './atoms/build-test.js';
export * from './atoms/built-ins.js';
export * from './atoms/code-import.js';
export * from './atoms/design-extract.js';
export * from './atoms/diff-review.js';
export * from './atoms/diff-review-genui-bridge.js';
export * from './atoms/figma-extract.js';
export * from './atoms/handoff.js';
export * from './atoms/patch-edit.js';
export * from './atoms/registry.js';
export * from './atoms/rewrite-plan.js';
export * from './atoms/token-map.js';
export * from './bundled.js';
export * from './connector-gate.js';
export * from './export.js';
export * from './doctor.js';
export * from './installer.js';
export * from './lockfile.js';
export * from './persistence.js';
export * from './marketplaces.js';
export * from './pipeline.js';
export * from './pipeline-runner.js';
export * from './publish.js';
export * from './registry.js';
export * from './scaffold.js';
export * from './gc.js';
export * from './resolve-snapshot.js';
export * from './snapshots.js';
export * from './trust.js';
export * from './until.js';

View file

@ -0,0 +1,860 @@
// Plugin installer. Spec §7.2:
//
// - `./folder` / `/abs/path` — local-copy backend (Phase 1).
// - `github:owner/repo[@ref][/subpath]` — fetched from
// codeload.github.com as a tar.gz, extracted into a temp dir, then
// copied into the daemon data-root-derived plugin registry via the local
// backend.
// - `https://…tar.gz` / `…tgz` — same extraction path, no path-rewrite.
//
// Hard install constraints (spec §7.2 / plan §3.A6):
// - Reject path-traversal segments inside the source folder when copying.
// - Reject symlinks (we do not stage non-local pointers).
// - Cap copied tree size at 50 MiB by default.
// - Refuse to overwrite a different plugin id at the destination.
// - Tarball extraction inherits the same caps via tar's strict mode.
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { createHash } from 'node:crypto';
import { promises as fsp } from 'node:fs';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { x as tarExtract } from 'tar';
import {
defaultRegistryRoots,
deleteInstalledPlugin,
resolvePluginFolder,
upsertInstalledPlugin,
type ResolveOptions,
type RegistryRoots,
} from './registry.js';
import type {
InstalledPluginRecord,
MarketplaceTrust,
PluginSourceKind,
TrustTier,
} from '@open-design/contracts';
import type Database from 'better-sqlite3';
import { recordPluginEvent } from './events.js';
import { upsertPluginLockfileEntry } from './lockfile.js';
type SqliteDb = Database.Database;
export interface InstallProgressEvent {
kind: 'progress';
phase: 'resolving' | 'copying' | 'parsing' | 'persisting';
message: string;
}
export interface InstallSuccessEvent {
kind: 'success';
plugin: InstalledPluginRecord;
warnings: string[];
}
export interface InstallErrorEvent {
kind: 'error';
message: string;
warnings: string[];
}
export type InstallEvent = InstallProgressEvent | InstallSuccessEvent | InstallErrorEvent;
export interface InstallOptions {
source: string;
// Forwarded from daemon runtime context; defaults to defaultRegistryRoots()
// so daemon tests can point at a sandboxed data root.
roots?: RegistryRoots;
// 50 MiB default mirrors spec §7.2; tests pin a tighter cap.
maxBytes?: number;
// When true (the default), an existing install with the same id is
// replaced. Set false from CLI flows that want to surface a confirm step.
overwriteExisting?: boolean;
// Pluggable network fetcher for tests. Production injects globalThis.fetch.
// The contract: returns a ReadableStream of the gzipped tar bytes.
fetcher?: ArchiveFetcher;
// Plan §3.JJ1 — emit 'plugin.installed' (default) or
// 'plugin.upgraded' from the producer hook. The upgrade route
// sets this to 'upgraded' so consumers can distinguish the two
// operations in the live event stream.
eventKind?: 'installed' | 'upgraded';
sourceMarketplaceId?: string;
sourceMarketplaceEntryName?: string;
sourceMarketplaceEntryVersion?: string;
marketplaceTrust?: MarketplaceTrust;
resolvedSource?: string;
resolvedRef?: string;
manifestDigest?: string;
archiveIntegrity?: string;
// Optional runtime-data lockfile path. Daemon routes pass
// `<OD_DATA_DIR>/od-plugin-lock.json`; tests can point at temp dirs.
lockfilePath?: string;
}
export type ArchiveFetcher = (url: string) => Promise<{
ok: boolean;
status: number;
statusText: string;
body: Readable | null;
}>;
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
const SAFE_BASENAME = /^[a-z0-9][a-z0-9._-]*$/;
const GITHUB_SOURCE_RE = /^github:([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)(.*)$/;
const HTTPS_SOURCE_RE = /^https:\/\//i;
const GITHUB_REF_SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
interface GithubArchiveCandidate {
ref: string;
subpath?: string;
}
interface ParsedGithubSource {
owner: string;
repo: string;
candidates: GithubArchiveCandidate[];
}
interface GithubContentsEntry {
type?: string;
name?: string;
path?: string;
download_url?: string | null;
}
interface GithubContentsBudget {
bytes: number;
hash: ReturnType<typeof createHash>;
maxBytes: number;
}
// Top-level dispatcher. Picks the backend off the source string and yields
// the same InstallEvent stream regardless of where the bytes came from.
export async function* installPlugin(
db: SqliteDb,
opts: InstallOptions,
): AsyncGenerator<InstallEvent, void, void> {
if (opts.source.startsWith('github:')) {
yield* installFromGithub(db, opts);
return;
}
if (HTTPS_SOURCE_RE.test(opts.source)) {
yield* installFromHttpsArchive(db, opts);
return;
}
yield* installFromLocalFolder(db, opts);
}
// `github:owner/repo[@ref][/subpath]` → codeload tarball.
async function* installFromGithub(
db: SqliteDb,
opts: InstallOptions,
): AsyncGenerator<InstallEvent, void, void> {
const parsed = parseGithubSource(opts.source);
if (!parsed) {
yield {
kind: 'error',
message: `Malformed github source ${opts.source}; expected github:owner/repo[@ref][/subpath]`,
warnings: [],
};
return;
}
let lastError: string | undefined;
const triedUrls: string[] = [];
for (const candidate of parsed.candidates) {
if (candidate.subpath) {
const contentsUrl = githubContentsUrl(parsed.owner, parsed.repo, candidate.subpath, candidate.ref);
triedUrls.push(contentsUrl);
const buffered: InstallEvent[] = [];
for await (const ev of installFromGithubContents(db, opts, parsed, candidate, contentsUrl)) {
buffered.push(ev);
if (ev.kind === 'error') {
lastError = ev.message;
break;
}
if (ev.kind === 'success') {
for (const bufferedEvent of buffered) yield bufferedEvent;
return;
}
}
if (lastError && isRetryableGithubCandidateError(lastError)) continue;
if (lastError) break;
}
const tarballUrl = githubTarballUrl(parsed.owner, parsed.repo, candidate.ref);
triedUrls.push(tarballUrl);
const buffered: InstallEvent[] = [];
for await (const ev of installFromArchiveUrl(db, opts, tarballUrl, candidate.subpath)) {
buffered.push(ev);
if (ev.kind === 'error') {
lastError = ev.message;
break;
}
if (ev.kind === 'success') {
for (const bufferedEvent of buffered) yield bufferedEvent;
return;
}
}
if (!lastError || !isRetryableGithubCandidateError(lastError)) break;
}
yield {
kind: 'error',
message: lastError
? `${lastError}. Tried GitHub archive URL(s): ${triedUrls.join(', ')}`
: `GitHub source ${opts.source} did not produce an installable archive`,
warnings: [],
};
}
function parseGithubSource(source: string): ParsedGithubSource | null {
const match = GITHUB_SOURCE_RE.exec(source);
if (!match) return null;
const [, owner, repo, rest = ''] = match;
if (!owner || !repo) return null;
if (rest.length === 0) {
return { owner, repo, candidates: [{ ref: 'HEAD' }] };
}
if (rest.startsWith('/')) {
const subpath = sanitizeRelativePath(rest.slice(1));
return subpath ? { owner, repo, candidates: [{ ref: 'HEAD', subpath }] } : null;
}
if (!rest.startsWith('@')) return null;
const refAndMaybeSubpath = rest.slice(1);
const parts = refAndMaybeSubpath.split('/');
if (parts.length === 0 || parts.some((part) => !GITHUB_REF_SEGMENT_RE.test(part))) {
return null;
}
const candidates: GithubArchiveCandidate[] = [];
const seen = new Set<string>();
for (let refPartCount = 1; refPartCount <= parts.length; refPartCount += 1) {
const ref = parts.slice(0, refPartCount).join('/');
const subpathParts = parts.slice(refPartCount);
const subpath = subpathParts.length > 0
? sanitizeRelativePath(subpathParts.join('/'))
: undefined;
const key = `${ref}\0${subpath ?? ''}`;
if (!seen.has(key)) {
seen.add(key);
candidates.push({ ref, ...(subpath ? { subpath } : {}) });
}
}
return candidates.length > 0 ? { owner, repo, candidates } : null;
}
function githubTarballUrl(owner: string, repo: string, ref: string): string {
const encodedRef = ref.split('/').map((part) => encodeURIComponent(part)).join('/');
return `https://codeload.github.com/${owner}/${repo}/tar.gz/${encodedRef}`;
}
function githubContentsUrl(owner: string, repo: string, subpath: string, ref: string): string {
const encodedPath = sanitizeRelativePath(subpath)
.split(path.sep)
.map((part) => encodeURIComponent(part))
.join('/');
return `https://api.github.com/repos/${owner}/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`;
}
function isRetryableGithubCandidateError(message: string): boolean {
return /^Fetch failed: 404\b/.test(message) || /^Subpath .+ not found inside archive$/.test(message);
}
async function* installFromGithubContents(
db: SqliteDb,
opts: InstallOptions,
parsed: ParsedGithubSource,
candidate: GithubArchiveCandidate,
contentsUrl: string,
): AsyncGenerator<InstallEvent, void, void> {
if (!candidate.subpath) return;
const fetcher = opts.fetcher ?? defaultFetcher;
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-plugin-github-contents-'));
const stagingFolder = path.join(tmpRoot, 'plugin');
try {
yield {
kind: 'progress',
phase: 'resolving',
message: `Fetching GitHub contents ${contentsUrl}`,
};
await fsp.mkdir(stagingFolder, { recursive: true });
const budget: GithubContentsBudget = {
bytes: 0,
hash: createHash('sha256'),
maxBytes,
};
try {
await copyGithubContentsPath(
fetcher,
parsed.owner,
parsed.repo,
candidate.ref,
candidate.subpath,
stagingFolder,
budget,
);
} catch (err) {
yield {
kind: 'error',
message: (err as Error).message,
warnings: [],
};
return;
}
yield* installFromLocalFolder(db, {
...opts,
archiveIntegrity: opts.archiveIntegrity ?? `sha256:${budget.hash.digest('hex')}`,
source: opts.source,
_stagedFolder: stagingFolder,
_stagedSourceKind: 'github',
} as InstallOptions & { _stagedFolder?: string; _stagedSourceKind?: PluginSourceKind });
} finally {
await fsp.rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
}
}
async function copyGithubContentsPath(
fetcher: ArchiveFetcher,
owner: string,
repo: string,
ref: string,
githubPath: string,
destPath: string,
budget: GithubContentsBudget,
): Promise<void> {
const contentsUrl = githubContentsUrl(owner, repo, githubPath, ref);
const payload = await fetchGithubJson(fetcher, contentsUrl);
const entries = Array.isArray(payload) ? payload : [payload];
if (entries.length === 0) {
throw new Error(`Subpath ${githubPath} not found inside repository`);
}
for (const entry of entries) {
const name = safeGithubEntryName(entry.name);
const childDest = Array.isArray(payload) ? path.join(destPath, name) : destPath;
if (entry.type === 'dir') {
const childPath = entry.path ?? path.posix.join(githubPath, name);
await fsp.mkdir(childDest, { recursive: true });
await copyGithubContentsPath(fetcher, owner, repo, ref, childPath, childDest, budget);
continue;
}
if (entry.type === 'file') {
if (!entry.download_url) {
throw new Error(`GitHub file ${entry.path ?? name} does not expose a download URL`);
}
await fsp.mkdir(path.dirname(childDest), { recursive: true });
await copyGithubFile(fetcher, entry.download_url, childDest, budget);
continue;
}
throw new Error(`GitHub entry ${entry.path ?? name} has unsupported type ${entry.type ?? 'unknown'}`);
}
}
async function fetchGithubJson(fetcher: ArchiveFetcher, url: string): Promise<GithubContentsEntry[] | GithubContentsEntry> {
const resp = await fetcher(url);
if (!resp.ok || !resp.body) {
throw new Error(`Fetch failed: ${resp.status} ${resp.statusText} for ${url}`);
}
const text = await readStreamText(resp.body, 1024 * 1024);
try {
return JSON.parse(text) as GithubContentsEntry[] | GithubContentsEntry;
} catch (err) {
throw new Error(`GitHub contents response was not valid JSON for ${url}: ${(err as Error).message}`);
}
}
async function copyGithubFile(
fetcher: ArchiveFetcher,
url: string,
destPath: string,
budget: GithubContentsBudget,
): Promise<void> {
const resp = await fetcher(url);
if (!resp.ok || !resp.body) {
throw new Error(`Fetch failed: ${resp.status} ${resp.statusText} for ${url}`);
}
const digestStream = new Transform({
transform(chunk: Buffer, _encoding, callback) {
budget.bytes += chunk.length;
if (budget.bytes > budget.maxBytes) {
callback(new Error(`Downloaded GitHub contents exceed ${budget.maxBytes} bytes`));
return;
}
budget.hash.update(chunk);
callback(null, chunk);
},
});
await pipeline(resp.body as NodeJS.ReadableStream, digestStream, fs.createWriteStream(destPath));
}
async function readStreamText(body: Readable, maxBytes: number): Promise<string> {
const chunks: Buffer[] = [];
let bytes = 0;
for await (const chunk of body) {
const buf = Buffer.from(chunk as Buffer);
bytes += buf.length;
if (bytes > maxBytes) {
throw new Error(`Response body exceeds ${maxBytes} bytes`);
}
chunks.push(buf);
}
return Buffer.concat(chunks).toString('utf8');
}
function safeGithubEntryName(name: string | undefined): string {
if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
throw new Error(`Unsafe GitHub contents entry name: ${name ?? '(missing)'}`);
}
return name;
}
// Plain `https://…tar.gz` / `https://…tgz` source.
async function* installFromHttpsArchive(
db: SqliteDb,
opts: InstallOptions,
): AsyncGenerator<InstallEvent, void, void> {
if (!/\.t(?:ar\.)?gz$/i.test(opts.source)) {
yield {
kind: 'error',
message: `Only .tar.gz / .tgz archives are accepted from https sources (got ${opts.source})`,
warnings: [],
};
return;
}
yield {
kind: 'progress',
phase: 'resolving',
message: `Fetching ${opts.source}`,
};
yield* installFromArchiveUrl(db, opts, opts.source, undefined);
}
async function* installFromArchiveUrl(
db: SqliteDb,
opts: InstallOptions,
url: string,
subpath: string | undefined,
): AsyncGenerator<InstallEvent, void, void> {
const fetcher = opts.fetcher ?? defaultFetcher;
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-plugin-archive-'));
try {
const resp = await fetcher(url);
if (!resp.ok || !resp.body) {
yield {
kind: 'error',
message: `Fetch failed: ${resp.status} ${resp.statusText} for ${url}`,
warnings: [],
};
return;
}
const archivePath = path.join(tmpRoot, 'archive.tgz');
let computedIntegrity: string;
try {
computedIntegrity = await writeArchiveAndDigest(resp.body, archivePath, maxBytes);
} catch (err) {
yield {
kind: 'error',
message: `Archive download failed: ${(err as Error).message}`,
warnings: [],
};
return;
}
if (opts.archiveIntegrity && !integrityMatches(opts.archiveIntegrity, computedIntegrity)) {
yield {
kind: 'error',
message: `Archive integrity mismatch: expected ${opts.archiveIntegrity}, got ${computedIntegrity}`,
warnings: [],
};
return;
}
yield { kind: 'progress', phase: 'copying', message: 'Extracting archive' };
let symlinkSeen = false;
let traversalSeen = false;
try {
// The tar package handles gzip decompression. We pass `strip: 1`
// because codeload tarballs always wrap the repo in a single
// `repo-<sha>/` folder, and we want the manifest to land at
// tmpRoot/<files>. The filter rejects symlinks / hard links and
// any path-traversal segment; we then surface those as a clean
// install error instead of silently skipping unsafe entries.
await pipeline(
fs.createReadStream(archivePath),
tarExtract({
cwd: tmpRoot,
strip: 1,
filter: (filePath, entry) => {
const entryType = (entry as { type?: string }).type;
if (entryType === 'SymbolicLink' || entryType === 'Link') {
symlinkSeen = true;
return false;
}
if (filePath.includes('..')) {
traversalSeen = true;
return false;
}
return true;
},
}) as NodeJS.WritableStream,
);
} catch (err) {
yield {
kind: 'error',
message: `Archive extraction failed: ${(err as Error).message}`,
warnings: [],
};
return;
}
if (symlinkSeen) {
yield {
kind: 'error',
message: 'Archive contains symbolic / hard links — refusing to stage non-local pointers',
warnings: [],
};
return;
}
if (traversalSeen) {
yield {
kind: 'error',
message: 'Archive contains path-traversal segments — refusing to stage',
warnings: [],
};
return;
}
// Pre-flight size check inside the staging dir.
const total = await measureTreeSize(tmpRoot);
if (total > maxBytes) {
yield {
kind: 'error',
message: `Extracted archive exceeds ${maxBytes} bytes (size=${total})`,
warnings: [],
};
return;
}
const stagingFolder = subpath
? path.join(tmpRoot, sanitizeRelativePath(subpath))
: tmpRoot;
if (!fs.existsSync(stagingFolder)) {
yield {
kind: 'error',
message: `Subpath ${subpath} not found inside archive`,
warnings: [],
};
return;
}
// Hand off to the local-folder backend so the registry write is the
// single canonical implementation. The `source` string is the
// original (github:… or https://…) so installed_plugins records
// provenance accurately.
yield* installFromLocalFolder(db, {
...opts,
archiveIntegrity: opts.archiveIntegrity ?? computedIntegrity,
source: opts.source,
// Drive the local backend through the staged folder; the
// override on `_stagedFolder` is internal and lets us re-use the
// copy / re-parse / persist phases without forking the function.
_stagedFolder: stagingFolder,
_stagedSourceKind: opts.source.startsWith('github:') ? 'github' : 'url',
} as InstallOptions & { _stagedFolder?: string; _stagedSourceKind?: PluginSourceKind });
} finally {
await fsp.rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
}
}
async function defaultFetcher(url: string): ReturnType<ArchiveFetcher> {
const response = await fetch(url, { redirect: 'follow' });
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
body: response.body ? Readable.fromWeb(response.body as never) : null,
};
}
async function writeArchiveAndDigest(
body: Readable,
archivePath: string,
maxBytes: number,
): Promise<string> {
const hash = createHash('sha256');
let bytes = 0;
const digestStream = new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytes += chunk.length;
if (bytes > maxBytes) {
callback(new Error(`Downloaded archive exceeds ${maxBytes} bytes`));
return;
}
hash.update(chunk);
callback(null, chunk);
},
});
await pipeline(body as NodeJS.ReadableStream, digestStream, fs.createWriteStream(archivePath));
return `sha256:${hash.digest('hex')}`;
}
function integrityMatches(expected: string, computed: string): boolean {
const normalizedExpected = expected.trim();
const normalizedComputed = computed.trim();
if (normalizedExpected === normalizedComputed) return true;
if (normalizedExpected.startsWith('sha256-')) {
const hex = normalizedComputed.replace(/^sha256:/, '');
const base64 = Buffer.from(hex, 'hex').toString('base64');
return normalizedExpected === `sha256-${base64}`;
}
return false;
}
async function measureTreeSize(root: string): Promise<number> {
let total = 0;
const queue: string[] = [root];
while (queue.length > 0) {
const next = queue.pop()!;
const stat = await fsp.lstat(next);
if (stat.isDirectory()) {
const entries = await fsp.readdir(next);
for (const entry of entries) queue.push(path.join(next, entry));
} else if (stat.isFile()) {
total += stat.size;
}
}
return total;
}
function sanitizeRelativePath(input: string): string {
return input
.replace(/^[\\/]+/, '')
.split(/[\\/]+/)
.filter((seg) => seg !== '..' && seg !== '.' && seg !== '')
.join(path.sep);
}
export async function* installFromLocalFolder(
db: SqliteDb,
opts: InstallOptions & { _stagedFolder?: string; _stagedSourceKind?: PluginSourceKind },
): AsyncGenerator<InstallEvent, void, void> {
const warnings: string[] = [];
const roots = opts.roots ?? defaultRegistryRoots();
// When called from the archive backend, the bytes are already on disk
// under `_stagedFolder`; the public `source` field still records
// provenance (github:owner/repo, https://example.com/foo.tgz, etc.).
const sourceFolder = opts._stagedFolder ?? path.resolve(opts.source);
const recordedSource = opts.source;
const recordedSourceKind: PluginSourceKind = opts._stagedSourceKind ?? 'local';
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
yield { kind: 'progress', phase: 'resolving', message: `Resolving ${sourceFolder}` };
let stats: fs.Stats;
try {
stats = await fsp.stat(sourceFolder);
} catch (err) {
yield { kind: 'error', message: `Source folder not found: ${sourceFolder} (${(err as Error).message})`, warnings };
return;
}
if (!stats.isDirectory()) {
yield { kind: 'error', message: `Source path is not a directory: ${sourceFolder}`, warnings };
return;
}
// Probe the source manifest first so the destination folder name is
// chosen by manifest id, not by directory name. This keeps registry
// ids deterministic when authors rename the folder on disk between
// installs.
yield { kind: 'progress', phase: 'parsing', message: 'Parsing manifest' };
const tentativeId = path.basename(sourceFolder).toLowerCase();
const probeOptions = buildResolveOptions({
folder: sourceFolder,
folderId: SAFE_BASENAME.test(tentativeId) ? tentativeId : 'plugin',
sourceKind: recordedSourceKind,
source: recordedSource,
}, opts);
const probe = await resolvePluginFolder(probeOptions);
if (!probe.ok) {
yield { kind: 'error', message: probe.errors.join('; '), warnings: probe.warnings };
return;
}
warnings.push(...probe.warnings);
const pluginId = probe.record.id;
if (!SAFE_BASENAME.test(pluginId)) {
yield { kind: 'error', message: `Plugin id '${pluginId}' is not a safe folder name`, warnings };
return;
}
const destFolder = path.join(roots.userPluginsRoot, pluginId);
// Block overwriting a foreign plugin id. The destination folder may
// contain a previous version of the same id, in which case we replace it.
if (fs.existsSync(destFolder) && (opts.overwriteExisting ?? true) === false) {
yield { kind: 'error', message: `Destination folder already exists: ${destFolder}. Pass overwriteExisting=true to replace.`, warnings };
return;
}
yield { kind: 'progress', phase: 'copying', message: `Copying to ${destFolder}` };
await fsp.mkdir(roots.userPluginsRoot, { recursive: true });
if (fs.existsSync(destFolder)) {
await fsp.rm(destFolder, { recursive: true, force: true });
}
try {
await safeCopyTree(sourceFolder, destFolder, maxBytes);
} catch (err) {
yield { kind: 'error', message: `Copy failed: ${(err as Error).message}`, warnings };
await fsp.rm(destFolder, { recursive: true, force: true }).catch(() => undefined);
return;
}
yield { kind: 'progress', phase: 'parsing', message: 'Re-parsing destination' };
const parsedOptions = buildResolveOptions({
folder: destFolder,
folderId: pluginId,
sourceKind: recordedSourceKind,
source: recordedSource,
}, opts);
const parsed = await resolvePluginFolder(parsedOptions);
if (!parsed.ok) {
await fsp.rm(destFolder, { recursive: true, force: true }).catch(() => undefined);
yield { kind: 'error', message: parsed.errors.join('; '), warnings: [...warnings, ...parsed.warnings] };
return;
}
warnings.push(...parsed.warnings);
yield { kind: 'progress', phase: 'persisting', message: 'Writing installed_plugins row' };
upsertInstalledPlugin(db, parsed.record);
if (opts.lockfilePath) {
await upsertPluginLockfileEntry(opts.lockfilePath, parsed.record);
}
// Plan §3.II1 / §3.JJ1 — emit 'plugin.installed' OR
// 'plugin.upgraded' (per opts.eventKind) so ops dashboards +
// `od plugin events tail` see the operation land in the in-
// memory ring buffer. Best-effort; recordPluginEvent never
// throws.
recordPluginEvent({
kind: opts.eventKind === 'upgraded' ? 'plugin.upgraded' : 'plugin.installed',
pluginId: parsed.record.id,
details: {
version: parsed.record.version,
sourceKind: parsed.record.sourceKind,
source: parsed.record.source,
sourceMarketplaceId: parsed.record.sourceMarketplaceId,
sourceMarketplaceEntryName: parsed.record.sourceMarketplaceEntryName,
sourceMarketplaceEntryVersion: parsed.record.sourceMarketplaceEntryVersion,
marketplaceTrust: parsed.record.marketplaceTrust,
trust: parsed.record.trust,
warnings: warnings.length,
},
});
yield { kind: 'success', plugin: parsed.record, warnings };
}
export interface UninstallResult {
ok: boolean;
removedFolder?: string;
warning?: string;
}
export async function uninstallPlugin(
db: SqliteDb,
id: string,
roots: RegistryRoots = defaultRegistryRoots(),
): Promise<UninstallResult> {
const removed = deleteInstalledPlugin(db, id);
const folder = path.join(roots.userPluginsRoot, id);
let removedFolder: string | undefined;
try {
await fsp.rm(folder, { recursive: true, force: true });
if (fs.existsSync(folder)) {
// Some platforms refuse to remove read-only files; surface a hint
// instead of silently leaving stale on-disk state.
return { ok: removed, warning: `Folder ${folder} could not be removed` };
}
removedFolder = folder;
} catch (err) {
return { ok: removed, warning: `Folder ${folder} removal failed: ${(err as Error).message}` };
}
// Plan §3.II1 — emit a 'plugin.uninstalled' event when the
// registry row was actually removed. We skip the event when
// both removed=false AND folder didn't exist (no-op uninstall).
if (removed || removedFolder !== undefined) {
recordPluginEvent({
kind: 'plugin.uninstalled',
pluginId: id,
details: removedFolder ? { removedFolder } : {},
});
}
return { ok: removed || removedFolder !== undefined, removedFolder };
}
// Recursive copy with budget tracking. Symlinks anywhere in the tree fail
// the copy outright; we never reach upstream paths through a clever link.
async function safeCopyTree(src: string, dest: string, maxBytes: number): Promise<void> {
let bytesCopied = 0;
const queue: Array<{ src: string; dest: string }> = [{ src, dest }];
while (queue.length > 0) {
const { src: from, dest: to } = queue.pop()!;
const stat = await fsp.lstat(from);
if (stat.isSymbolicLink()) {
throw new Error(`Symbolic link rejected: ${from}`);
}
if (stat.isDirectory()) {
await fsp.mkdir(to, { recursive: true });
const entries = await fsp.readdir(from, { withFileTypes: true });
for (const entry of entries) {
if (!isSafeBasename(entry.name)) {
throw new Error(`Unsafe path segment: ${entry.name}`);
}
queue.push({ src: path.join(from, entry.name), dest: path.join(to, entry.name) });
}
continue;
}
if (stat.isFile()) {
bytesCopied += stat.size;
if (bytesCopied > maxBytes) {
throw new Error(`Plugin tree exceeds size cap of ${maxBytes} bytes`);
}
await fsp.copyFile(from, to);
continue;
}
// Sockets / fifos / devices — refuse.
throw new Error(`Unsupported file type at ${from}`);
}
}
function isSafeBasename(name: string): boolean {
if (name === '.' || name === '..') return false;
if (name.includes('/') || name.includes('\\') || name.includes('\0')) return false;
return true;
}
function buildResolveOptions(
base: Pick<ResolveOptions, 'folder' | 'folderId' | 'sourceKind' | 'source'>,
opts: InstallOptions,
): ResolveOptions {
const resolveOptions: ResolveOptions = { ...base };
if (opts.sourceMarketplaceId) resolveOptions.sourceMarketplaceId = opts.sourceMarketplaceId;
if (opts.sourceMarketplaceEntryName) resolveOptions.sourceMarketplaceEntryName = opts.sourceMarketplaceEntryName;
if (opts.sourceMarketplaceEntryVersion) resolveOptions.sourceMarketplaceEntryVersion = opts.sourceMarketplaceEntryVersion;
if (opts.marketplaceTrust) {
resolveOptions.marketplaceTrust = opts.marketplaceTrust;
resolveOptions.trust = installedTrustFromMarketplace(opts.marketplaceTrust);
}
if (opts.resolvedSource) resolveOptions.resolvedSource = opts.resolvedSource;
if (opts.resolvedRef) resolveOptions.resolvedRef = opts.resolvedRef;
if (opts.manifestDigest) resolveOptions.manifestDigest = opts.manifestDigest;
if (opts.archiveIntegrity) resolveOptions.archiveIntegrity = opts.archiveIntegrity;
return resolveOptions;
}
function installedTrustFromMarketplace(trust: MarketplaceTrust): TrustTier {
return trust === 'restricted' ? 'restricted' : 'trusted';
}
export type { PluginSourceKind };

View file

@ -0,0 +1,75 @@
// Plugin-local SKILL.md loader (Stage A of plugin-driven-flow-plan).
//
// Plugins that declare `od.context.skills[{ path: './SKILL.md' }]` ship
// their own skill body inside their plugin folder. Those files never
// register against the global skills registry, so the
// `composeSystemPrompt` skill slot would otherwise be empty.
//
// This module is the lone reader of plugin-local SKILL.md files. It
// stays separate from `apply.ts` because apply.ts is intentionally pure
// (no filesystem reads) — the daemon calls this loader during prompt
// composition, not during snapshot apply.
//
// The returned record mirrors the shape `composeDaemonSystemPrompt`
// already consumes for global skills (`body`, `name`, `dir`) so the
// override is a drop-in.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import type { InstalledPluginRecord } from '@open-design/contracts';
import { pickFirstLocalSkillPath } from './apply.js';
export interface PluginLocalSkill {
body: string;
name: string;
// Absolute directory containing the SKILL.md — used by
// `stageActiveSkill` to copy companion files into the project cwd.
dir: string;
// Relative path inside the plugin folder, kept for debugging /
// logging. Always normalised (no leading './').
relpath: string;
}
export async function loadPluginLocalSkill(
plugin: InstalledPluginRecord,
): Promise<PluginLocalSkill | null> {
const manifest = plugin.manifest;
const relpath = pickFirstLocalSkillPath(manifest);
if (!relpath) return null;
const safeRel = stripLeadingDotSlash(relpath);
// Guard against path traversal — the manifest is trusted but we still
// refuse `..` escapes so a bad plugin author can't reach outside its
// own fsPath.
if (safeRel.split('/').some((segment) => segment === '..')) return null;
const abs = path.join(plugin.fsPath, safeRel);
let raw: string;
try {
raw = await fsp.readFile(abs, 'utf8');
} catch {
return null;
}
const body = stripFrontmatter(raw).trim();
if (!body) return null;
const name = (manifest.title ?? manifest.name ?? plugin.id).toString();
return {
body,
name,
dir: path.dirname(abs),
relpath: safeRel,
};
}
function stripLeadingDotSlash(value: string): string {
return value.startsWith('./') ? value.slice(2) : value;
}
// Mirrors the loader inside `atom-bodies.ts`. Kept duplicated here on
// purpose: atom-bodies is the lone reader for atom SKILL.md, and we do
// not want to grow a cross-file import surface for one regex.
function stripFrontmatter(raw: string): string {
if (!raw.startsWith('---')) return raw;
const closeIdx = raw.indexOf('\n---', 3);
if (closeIdx === -1) return raw;
const after = raw.slice(closeIdx + 4);
return after.replace(/^\r?\n/, '');
}

View file

@ -0,0 +1,87 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { InstalledPluginRecord } from '@open-design/contracts';
export interface PluginLockEntry {
name: string;
version: string;
source: string;
sourceKind: string;
sourceMarketplaceId?: string;
sourceMarketplaceEntryName?: string;
resolvedSource?: string;
resolvedRef?: string;
manifestDigest?: string;
archiveIntegrity?: string;
lockedAt: number;
}
export interface PluginLockfile {
schemaVersion: 1;
plugins: Record<string, PluginLockEntry>;
}
export function defaultPluginLockfile(): PluginLockfile {
return { schemaVersion: 1, plugins: {} };
}
export function lockEntryFromInstalled(
plugin: InstalledPluginRecord,
lockedAt = Date.now(),
): PluginLockEntry {
const entry: PluginLockEntry = {
name: plugin.sourceMarketplaceEntryName ?? plugin.id,
version: plugin.sourceMarketplaceEntryVersion ?? plugin.version,
source: plugin.source,
sourceKind: plugin.sourceKind,
lockedAt,
};
if (plugin.sourceMarketplaceId) entry.sourceMarketplaceId = plugin.sourceMarketplaceId;
if (plugin.sourceMarketplaceEntryName) entry.sourceMarketplaceEntryName = plugin.sourceMarketplaceEntryName;
if (plugin.resolvedSource) entry.resolvedSource = plugin.resolvedSource;
if (plugin.resolvedRef) entry.resolvedRef = plugin.resolvedRef;
if (plugin.manifestDigest) entry.manifestDigest = plugin.manifestDigest;
if (plugin.archiveIntegrity) entry.archiveIntegrity = plugin.archiveIntegrity;
return entry;
}
export async function readPluginLockfile(filePath: string): Promise<PluginLockfile> {
try {
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as PluginLockfile;
return {
schemaVersion: 1,
plugins: parsed && typeof parsed.plugins === 'object' && parsed.plugins
? parsed.plugins
: {},
};
} catch {
return defaultPluginLockfile();
}
}
export async function writePluginLockfile(
filePath: string,
lockfile: PluginLockfile,
): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
const sorted: PluginLockfile = {
schemaVersion: 1,
plugins: Object.fromEntries(
Object.entries(lockfile.plugins).sort(([left], [right]) => left.localeCompare(right)),
),
};
await writeFile(filePath, JSON.stringify(sorted, null, 2) + '\n', 'utf8');
}
export async function upsertPluginLockfileEntry(
filePath: string,
plugin: InstalledPluginRecord,
lockedAt = Date.now(),
): Promise<PluginLockfile> {
const lockfile = await readPluginLockfile(filePath);
const entry = lockEntryFromInstalled(plugin, lockedAt);
lockfile.plugins[entry.name] = entry;
await writePluginLockfile(filePath, lockfile);
return lockfile;
}

View file

@ -0,0 +1,81 @@
import type { MarketplaceManifest } from '@open-design/contracts';
import type { RegistryDoctorIssue, RegistryDoctorReport } from '@open-design/registry-protocol';
import { StaticRegistryBackend } from '../registry/static-backend.js';
export interface MarketplaceDoctorInput {
id: string;
trust: 'official' | 'trusted' | 'restricted';
manifest: MarketplaceManifest;
checkedAt?: number;
strict?: boolean;
}
export async function doctorMarketplace(
input: MarketplaceDoctorInput,
): Promise<RegistryDoctorReport & { warningsAsErrors: boolean }> {
const backend = new StaticRegistryBackend({
id: input.id,
trust: input.trust,
manifest: input.manifest,
});
const base = await backend.doctor();
const issues: RegistryDoctorIssue[] = [...base.issues];
const names = new Set<string>();
for (const entry of input.manifest.plugins ?? []) {
const lower = entry.name.toLowerCase();
if (names.has(lower)) {
issues.push({
severity: 'error',
code: 'duplicate-name',
message: 'Registry entries must have stable unique plugin ids.',
pluginName: entry.name,
});
}
names.add(lower);
if (entry.dist?.archive && !entry.dist.integrity && !entry.integrity) {
issues.push({
severity: 'error',
code: 'archive-integrity-required',
message: 'Archive distribution entries must include sha256 integrity.',
pluginName: entry.name,
});
}
if (entry.distTags?.latest) {
const hasLatest = (entry.versions ?? []).some((version) =>
version.version === entry.distTags?.latest && !version.yanked,
) || entry.version === entry.distTags.latest;
if (!hasLatest) {
issues.push({
severity: 'error',
code: 'bad-latest-tag',
message: 'distTags.latest must point at a non-yanked version.',
pluginName: entry.name,
});
}
}
const publisherId = entry.publisher?.id ?? entry.publisher?.github;
if (!publisherId) {
issues.push({
severity: 'warning',
code: 'missing-publisher',
message: 'Registry entry should declare publisher identity.',
pluginName: entry.name,
});
}
}
const strict = input.strict === true;
return {
ok: !issues.some((issue) => issue.severity === 'error') &&
(!strict || !issues.some((issue) => issue.severity === 'warning')),
backendId: base.backendId,
checkedAt: input.checkedAt ?? base.checkedAt,
entriesChecked: base.entriesChecked,
issues,
warningsAsErrors: strict,
};
}

View file

@ -0,0 +1,522 @@
// Marketplace registry — plan §3.B4 / spec §6 / §7 / §11.5 / §16 Phase 3
// (entry slice).
//
// Stores user-configured federated catalog indexes in
// `plugin_marketplaces`. The actual `od plugin install <name>` resolution
// through these catalogs lands in Phase 3 alongside the trust UI; this
// module is the storage + refresh half so the desktop / CLI can already
// register and inspect catalogs.
//
// We intentionally treat the catalog body as opaque JSON in v1 — Zod
// validation lives in `@open-design/plugin-runtime`'s parser and we only
// store what the parser returns. Trust default mirrors §9: a freshly
// added user-supplied marketplace is `restricted` (discovery only)
// unless `--trust` is passed.
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import {
parseMarketplace,
type MarketplaceParseResult,
} from '@open-design/plugin-runtime';
import {
OPEN_DESIGN_PLUGIN_SPEC_VERSION,
type MarketplaceManifest,
} from '@open-design/contracts';
import {
parsePluginSpecifier,
resolveMarketplaceEntryVersion,
} from '../registry/versioning.js';
type SqliteDb = Database.Database;
export type MarketplaceTrustTier = 'official' | 'trusted' | 'restricted';
export interface MarketplaceRow {
id: string;
url: string;
specVersion: string;
version: string;
trust: MarketplaceTrustTier;
manifest: MarketplaceManifest;
addedAt: number;
refreshedAt: number;
}
export interface AddMarketplaceInput {
url: string;
// Pluggable HTTPS fetcher; tests inject a stub. Production injects the
// global fetch.
fetcher?: (url: string) => Promise<{ ok: boolean; status: number; text: () => Promise<string> }>;
trust?: MarketplaceTrustTier;
}
export interface AddMarketplaceResult {
ok: true;
row: MarketplaceRow;
warnings: string[];
}
export interface AddMarketplaceFailure {
ok: false;
status: number;
message: string;
errors?: string[];
}
export interface EnsureMarketplaceManifestInput {
id: string;
url: string;
trust: MarketplaceTrustTier;
manifestText: string;
now?: number;
}
const HTTPS_RE = /^https:\/\//i;
const DEFAULT_MARKETPLACE_REPO = 'nexu-io/open-design';
const DEFAULT_MARKETPLACE_REPO_REF = 'main';
const DEFAULT_MARKETPLACE_REGISTRY_PATH = 'plugins/registry';
const PUBLIC_MARKETPLACE_BASE_URL = 'https://open-design.ai/marketplace';
const PUBLIC_PLUGINS_BASE_URL = 'https://open-design.ai/plugins';
function marketplaceRegistryRepo(): string {
return (process.env.OD_MARKETPLACE_REPO?.trim() || DEFAULT_MARKETPLACE_REPO)
.replace(/^\/+|\/+$/g, '');
}
export function marketplaceRegistryBaseUrl(): string {
const explicit = process.env.OD_MARKETPLACE_REGISTRY_BASE_URL?.trim();
if (explicit) return explicit.replace(/\/+$/, '');
const repo = marketplaceRegistryRepo();
const ref = (process.env.OD_MARKETPLACE_REPO_REF?.trim() || DEFAULT_MARKETPLACE_REPO_REF)
.replace(/^\/+|\/+$/g, '');
const registryPath = (process.env.OD_MARKETPLACE_REGISTRY_PATH?.trim() || DEFAULT_MARKETPLACE_REGISTRY_PATH)
.replace(/^\/+|\/+$/g, '');
return `https://raw.githubusercontent.com/${repo}/${ref}/${registryPath}`;
}
export function marketplaceManifestUrlForRegistry(id: string): string {
const registryId = id.trim().replace(/^\/+|\/+$/g, '');
return `${marketplaceRegistryBaseUrl()}/${registryId}/open-design-marketplace.json`;
}
function registryIdFromBaseUrl(url: string, baseUrl: string): string | null {
const base = baseUrl.replace(/\/+$/, '');
if (!url.startsWith(`${base}/`) || !url.endsWith('/open-design-marketplace.json')) {
return null;
}
const id = url
.slice(base.length + 1)
.replace(/\/open-design-marketplace\.json$/, '');
return id && !id.includes('/') ? id : null;
}
export function marketplaceRegistryIdFromUrl(url: string): string | null {
const trimmed = url.trim();
if (!trimmed) return null;
const configuredId = registryIdFromBaseUrl(trimmed, marketplaceRegistryBaseUrl());
if (configuredId) return configuredId;
const publicBases = [PUBLIC_MARKETPLACE_BASE_URL, PUBLIC_PLUGINS_BASE_URL];
for (const base of publicBases) {
if (trimmed === `${base}/open-design-marketplace.json`) return 'official';
if (trimmed.startsWith(`${base}/`) && trimmed.endsWith('/open-design-marketplace.json')) {
const id = trimmed
.slice(base.length + 1)
.replace(/\/open-design-marketplace\.json$/, '');
if (id && !id.includes('/')) return id;
}
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'https:' || parsed.hostname !== 'raw.githubusercontent.com') {
return null;
}
const parts = parsed.pathname.split('/').filter(Boolean);
if (parts.length < 6) return null;
const [owner, repo] = parts;
const allowedRepos = new Set([DEFAULT_MARKETPLACE_REPO, marketplaceRegistryRepo()]);
if (!allowedRepos.has(`${owner}/${repo}`)) return null;
const marker = parts.findIndex((part, index) =>
part === 'plugins' && parts[index + 1] === 'registry',
);
const id = marker >= 0 ? parts[marker + 2] : undefined;
const filename = marker >= 0 ? parts[marker + 3] : undefined;
return id && filename === 'open-design-marketplace.json' ? id : null;
} catch {
return null;
}
}
export function resolveMarketplaceFetchUrl(url: string): string {
const trimmed = url.trim();
const registryId = marketplaceRegistryIdFromUrl(trimmed);
return registryId ? marketplaceManifestUrlForRegistry(registryId) : trimmed;
}
function normalizeMarketplaceTrust(value: unknown): MarketplaceTrustTier {
return value === 'official' || value === 'trusted' ? value : 'restricted';
}
export async function addMarketplace(
db: SqliteDb,
input: AddMarketplaceInput,
): Promise<AddMarketplaceResult | AddMarketplaceFailure> {
const url = resolveMarketplaceFetchUrl(input.url);
if (!HTTPS_RE.test(url)) {
return {
ok: false,
status: 400,
message: 'marketplace url must use https://',
};
}
const fetcher = input.fetcher ?? defaultFetcher;
let resp;
try {
resp = await fetcher(url);
} catch (err) {
return {
ok: false,
status: 502,
message: `Fetch failed: ${(err as Error).message ?? String(err)}`,
};
}
if (!resp.ok) {
return {
ok: false,
status: 502,
message: `Marketplace fetch returned ${resp.status}`,
};
}
const text = await resp.text();
const parsed: MarketplaceParseResult = parseMarketplace(text);
if (!parsed.ok) {
return {
ok: false,
status: 422,
message: 'marketplace manifest failed validation',
errors: parsed.errors,
};
}
const id = randomUUID();
const now = Date.now();
const trust = normalizeMarketplaceTrust(input.trust);
db.prepare(
`INSERT INTO plugin_marketplaces (id, url, spec_version, version, trust, manifest_json, added_at, refreshed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(id, url, parsed.manifest.specVersion, parsed.manifest.version, trust, text, now, now);
return {
ok: true,
row: {
id,
url,
specVersion: parsed.manifest.specVersion,
version: parsed.manifest.version,
trust,
manifest: parsed.manifest,
addedAt: now,
refreshedAt: now,
},
warnings: [],
};
}
export function ensureMarketplaceManifest(
db: SqliteDb,
input: EnsureMarketplaceManifestInput,
): AddMarketplaceResult | AddMarketplaceFailure {
const parsed = parseMarketplace(input.manifestText);
if (!parsed.ok) {
return {
ok: false,
status: 422,
message: 'marketplace manifest failed validation',
errors: parsed.errors,
};
}
const now = input.now ?? Date.now();
const trust = normalizeMarketplaceTrust(input.trust);
const existing = getMarketplace(db, input.id);
db.prepare(`
INSERT INTO plugin_marketplaces (id, url, spec_version, version, trust, manifest_json, added_at, refreshed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
url = excluded.url,
spec_version = excluded.spec_version,
version = excluded.version,
trust = excluded.trust,
manifest_json = excluded.manifest_json,
refreshed_at = excluded.refreshed_at
`).run(
input.id,
input.url,
parsed.manifest.specVersion,
parsed.manifest.version,
trust,
input.manifestText,
existing?.addedAt ?? now,
now,
);
return {
ok: true,
row: {
id: input.id,
url: input.url,
specVersion: parsed.manifest.specVersion,
version: parsed.manifest.version,
trust,
manifest: parsed.manifest,
addedAt: existing?.addedAt ?? now,
refreshedAt: now,
},
warnings: [],
};
}
export function listMarketplaces(db: SqliteDb): MarketplaceRow[] {
const rows = db
.prepare(`SELECT id, url, spec_version, version, trust, manifest_json, added_at, refreshed_at FROM plugin_marketplaces ORDER BY added_at ASC`)
.all() as Array<{
id: string;
url: string;
spec_version: string;
version: string;
trust: MarketplaceTrustTier;
manifest_json: string;
added_at: number;
refreshed_at: number;
}>;
return rows.map((r) => {
const manifest = safeParseManifest(r.manifest_json);
return {
id: r.id,
url: r.url,
specVersion: r.spec_version || manifest.specVersion,
version: r.version === '0.0.0' ? manifest.version : r.version,
trust: normalizeMarketplaceTrust(r.trust),
manifest,
addedAt: r.added_at,
refreshedAt: r.refreshed_at,
};
});
}
export function getMarketplace(db: SqliteDb, id: string): MarketplaceRow | null {
const row = db
.prepare(`SELECT id, url, spec_version, version, trust, manifest_json, added_at, refreshed_at FROM plugin_marketplaces WHERE id = ?`)
.get(id) as
| undefined
| {
id: string;
url: string;
spec_version: string;
version: string;
trust: MarketplaceTrustTier;
manifest_json: string;
added_at: number;
refreshed_at: number;
};
if (!row) return null;
const manifest = safeParseManifest(row.manifest_json);
return {
id: row.id,
url: row.url,
specVersion: row.spec_version || manifest.specVersion,
version: row.version === '0.0.0' ? manifest.version : row.version,
trust: normalizeMarketplaceTrust(row.trust),
manifest,
addedAt: row.added_at,
refreshedAt: row.refreshed_at,
};
}
export function removeMarketplace(db: SqliteDb, id: string): boolean {
const info = db.prepare(`DELETE FROM plugin_marketplaces WHERE id = ?`).run(id);
return info.changes > 0;
}
export function setMarketplaceTrust(
db: SqliteDb,
id: string,
trust: MarketplaceTrustTier,
): MarketplaceRow | null {
const info = db.prepare(`UPDATE plugin_marketplaces SET trust = ? WHERE id = ?`).run(trust, id);
if (info.changes === 0) return null;
return getMarketplace(db, id);
}
export interface RefreshMarketplaceResult {
ok: true;
row: MarketplaceRow;
}
export async function refreshMarketplace(
db: SqliteDb,
id: string,
fetcher?: AddMarketplaceInput['fetcher'],
): Promise<RefreshMarketplaceResult | AddMarketplaceFailure> {
const existing = getMarketplace(db, id);
if (!existing) {
return { ok: false, status: 404, message: `marketplace ${id} not found` };
}
const useFetcher = fetcher ?? defaultFetcher;
const url = resolveMarketplaceFetchUrl(existing.url);
let resp;
try {
resp = await useFetcher(url);
} catch (err) {
return { ok: false, status: 502, message: `Fetch failed: ${(err as Error).message ?? String(err)}` };
}
if (!resp.ok) return { ok: false, status: 502, message: `Marketplace fetch returned ${resp.status}` };
const text = await resp.text();
const parsed = parseMarketplace(text);
if (!parsed.ok) {
return { ok: false, status: 422, message: 'marketplace manifest failed validation', errors: parsed.errors };
}
const now = Date.now();
db.prepare(`UPDATE plugin_marketplaces SET url = ?, spec_version = ?, version = ?, manifest_json = ?, refreshed_at = ? WHERE id = ?`)
.run(url, parsed.manifest.specVersion, parsed.manifest.version, text, now, id);
return {
ok: true,
row: {
...existing,
url,
specVersion: parsed.manifest.specVersion,
version: parsed.manifest.version,
manifest: parsed.manifest,
refreshedAt: now,
},
};
}
async function defaultFetcher(url: string) {
const response = await fetch(url, { redirect: 'follow' });
return {
ok: response.ok,
status: response.status,
text: () => response.text(),
};
}
function safeParseManifest(raw: string): MarketplaceManifest {
try {
const parsed = parseMarketplace(raw);
if (parsed.ok) return parsed.manifest;
} catch {
// fall through
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('legacy marketplace manifest is not an object');
}
const legacy = parsed as Record<string, unknown>;
const metadata = typeof legacy['metadata'] === 'object' && legacy['metadata'] !== null
? legacy['metadata'] as Record<string, unknown>
: {};
const plugins = Array.isArray(legacy?.['plugins'])
? (legacy['plugins'] as unknown[]).flatMap((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return [];
const obj = entry as Record<string, unknown>;
const name = typeof obj['name'] === 'string' ? obj['name'] : '';
const source = typeof obj['source'] === 'string' ? obj['source'] : '';
if (!name || !source) return [];
return [{
...obj,
name,
source,
version: typeof obj['version'] === 'string' && obj['version'].length > 0
? obj['version']
: '0.0.0',
}];
})
: [];
return {
...legacy,
specVersion: typeof legacy['specVersion'] === 'string'
? legacy['specVersion'] as string
: OPEN_DESIGN_PLUGIN_SPEC_VERSION,
name: typeof legacy['name'] === 'string' ? legacy['name'] as string : 'unknown',
version: typeof legacy['version'] === 'string' && (legacy['version'] as string).length > 0
? legacy['version'] as string
: typeof metadata['version'] === 'string' && metadata['version'].length > 0
? metadata['version']
: '0.0.0',
plugins,
} as MarketplaceManifest;
} catch {
// fall through
}
// Last-resort fallback: return a minimal shape so the caller doesn't
// explode if a database row was stored before a schema patch.
return {
specVersion: OPEN_DESIGN_PLUGIN_SPEC_VERSION,
name: 'unknown',
version: '0.0.0',
plugins: [],
} as MarketplaceManifest;
}
// Plan §3.F3 / spec §7.2 + §6 — resolve a bare plugin name through
// every configured marketplace. Returns the first match (marketplace
// scan order matches `listMarketplaces` output, which is sorted by
// `added_at` ASC). The match carries the marketplace id (so audit
// trails record which catalog the install came from) and the resolved
// `source` string the installer can re-feed into `installPlugin()`.
//
// `restricted` marketplaces still resolve names — the plugin install
// path does NOT auto-trust the resulting plugin (it stays
// `restricted` per spec §9 unless the marketplace was explicitly
// `trusted` at add-time).
export interface ResolvedPluginEntry {
marketplaceId: string;
marketplaceUrl: string;
marketplaceTrust: MarketplaceTrustTier;
marketplaceSpecVersion: string;
marketplaceVersion: string;
pluginName: string;
pluginVersion: string;
source: string;
ref?: string;
manifestDigest?: string;
archiveIntegrity?: string;
description?: string;
}
export function resolvePluginInMarketplaces(
db: SqliteDb,
pluginName: string,
): ResolvedPluginEntry | null {
const rows = listMarketplaces(db);
const specifier = parsePluginSpecifier(pluginName);
const target = specifier.name.trim().toLowerCase();
if (!target) return null;
for (const row of rows) {
const entries = row.manifest.plugins ?? [];
for (const entry of entries) {
if (entry.name && entry.name.toLowerCase() === target) {
const resolvedVersion = resolveMarketplaceEntryVersion(entry, specifier.range);
if (!resolvedVersion) continue;
const result: ResolvedPluginEntry = {
marketplaceId: row.id,
marketplaceUrl: row.url,
marketplaceTrust: row.trust,
marketplaceSpecVersion: row.specVersion,
marketplaceVersion: row.version,
pluginName: entry.name,
pluginVersion: resolvedVersion.version,
source: resolvedVersion.source,
};
if (resolvedVersion.ref) result.ref = resolvedVersion.ref;
if (resolvedVersion.manifestDigest) result.manifestDigest = resolvedVersion.manifestDigest;
if (resolvedVersion.archiveIntegrity) result.archiveIntegrity = resolvedVersion.archiveIntegrity;
if (entry.description) result.description = entry.description;
return result;
}
}
}
return null;
}

View file

@ -0,0 +1,169 @@
// Phase 4 / spec §14 / plan §3.X1 — `od plugin pack <folder>`.
//
// Produces a gzip-compressed tar archive of a plugin folder so the
// author can hand it to a peer or upload it to a marketplace
// without going through GitHub. The installer's HTTPS-tarball
// path (§3.A6) consumes the same .tgz shape, so a packed archive
// is byte-equal to what `od plugin install --source <https://...>`
// would download.
//
// What we put in the archive:
// - open-design.json (required; this is what the installer
// resolves first)
// - SKILL.md / .claude-plugin/plugin.json when present
// - Any other plain files under the folder
//
// What we exclude:
// - node_modules / .git / dist / build / out / coverage
// (consistent with the installer's tarball-traversal skiplist
// — keeps archive size sane and prevents "ship my whole
// development setup" accidents)
// - .DS_Store / Thumbs.db (OS noise)
// - The output archive itself when --out lands inside the folder
// (would otherwise spiral)
//
// We do NOT chase symlinks (consistent with the installer's
// extract-time symlink rejection, §3.A6 plan).
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { c as tarCreate } from 'tar';
export interface PackPluginInput {
// Path to the plugin folder. Must contain open-design.json.
folder: string;
// Absolute path of the output archive. Default:
// `<folder>/../<folder-basename>-<version>.tgz` when the manifest
// ships a version, otherwise `<folder>/../<folder-basename>.tgz`.
out?: string;
}
export interface PackPluginResult {
outPath: string;
bytes: number;
// The set of files added to the archive (POSIX paths, relative
// to the folder). Useful for the CLI's audit log.
files: string[];
// Captured from the manifest at pack time so the CLI can echo
// back "packed my-plugin@0.1.2" without the caller re-reading.
pluginId?: string;
pluginVersion?: string;
}
const SKIP_DIRS = new Set([
'node_modules', '.git', '.next', 'dist', 'build', 'out', 'coverage',
'.turbo', '.cache', '.pnpm-store', '.parcel-cache', '.svelte-kit',
'.nuxt', '.astro', '.vercel', '.vscode',
]);
const SKIP_FILES = new Set(['.DS_Store', 'Thumbs.db']);
export class PackPluginError extends Error {
constructor(message: string) {
super(message);
this.name = 'PackPluginError';
}
}
export async function packPlugin(input: PackPluginInput): Promise<PackPluginResult> {
const folder = path.resolve(input.folder);
// Confirm the folder shape — open-design.json must exist + parse.
let manifestRaw: string;
try {
manifestRaw = await fsp.readFile(path.join(folder, 'open-design.json'), 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
throw new PackPluginError(`folder ${folder} does not contain open-design.json`);
}
throw new PackPluginError(`failed to read open-design.json: ${(err as Error).message}`);
}
let pluginId: string | undefined;
let pluginVersion: string | undefined;
try {
const parsed = JSON.parse(manifestRaw) as { name?: string; version?: string };
if (typeof parsed.name === 'string' && parsed.name.length > 0) pluginId = parsed.name;
if (typeof parsed.version === 'string' && parsed.version.length > 0) pluginVersion = parsed.version;
} catch (err) {
throw new PackPluginError(`open-design.json failed to parse as JSON: ${(err as Error).message}`);
}
const folderBase = path.basename(folder);
const defaultOut = pluginVersion
? `${folderBase}-${pluginVersion}.tgz`
: `${folderBase}.tgz`;
const outPath = path.resolve(input.out ?? path.join(path.dirname(folder), defaultOut));
// Collect every file we'll archive, building a set ahead of the
// tar.create call so we can audit the list + reject the case
// where the output path lands inside the folder.
const files: string[] = [];
await walk(folder, '', files, outPath);
files.sort();
// tar.create is symlink-aware via portable mode; the option
// `follow: false` is the default. We pass `cwd` so paths in the
// archive are folder-relative.
await tarCreate(
{
gzip: true,
file: outPath,
cwd: folder,
portable: true,
// Reject symlinks at write time — the installer rejects them
// at extract time too. Keeping the contract symmetric stops
// an author from packing a symlink and only finding out at
// install. The walker also pre-filters them; this is a
// belt-and-suspenders pass.
filter: (entryPath, stat) => {
const candidate = stat as { isSymbolicLink?: () => boolean };
if (typeof candidate.isSymbolicLink === 'function' && candidate.isSymbolicLink()) return false;
return true;
},
},
files,
);
let bytes = 0;
try {
const stat = await fsp.stat(outPath);
bytes = stat.size;
} catch {
bytes = 0;
}
const result: PackPluginResult = { outPath, bytes, files };
if (pluginId) result.pluginId = pluginId;
if (pluginVersion) result.pluginVersion = pluginVersion;
return result;
}
async function walk(
rootAbs: string,
rel: string,
out: string[],
outArchivePath: string,
): Promise<void> {
const abs = path.join(rootAbs, rel);
let entries;
try {
entries = await fsp.readdir(abs, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isSymbolicLink()) continue; // skip symlinks (see filter above)
const entryRel = rel ? `${rel}/${entry.name}` : entry.name;
const entryAbs = path.join(rootAbs, entryRel);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
await walk(rootAbs, entryRel, out, outArchivePath);
continue;
}
if (!entry.isFile()) continue;
if (SKIP_FILES.has(entry.name)) continue;
if (path.resolve(entryAbs) === outArchivePath) continue; // don't pack the archive itself
// POSIX paths in the manifest list keep the archive
// diff-friendly across platforms.
out.push(entryRel.split(path.sep).join('/'));
}
}

View file

@ -0,0 +1,194 @@
// Plugin-system SQLite migrations. Phase 1 shipped installed_plugins,
// plugin_marketplaces, applied_plugin_snapshots (full §11.4 shape with
// PB2 expires_at), and ALTER TABLE adds for projects / conversations to
// back-reference the applied snapshot. Phase 2A adds run_devloop_iterations
// (devloop audit + future per-iteration billing) and genui_surfaces
// (cross-conversation cache, F8 lookup rules).
//
// `runs` lives in-memory in `apps/daemon/src/runs.ts` today, so the
// run-level snapshot link is carried on the in-memory run object plus
// the messages.run_id row instead of a SQL ALTER TABLE.
import type Database from 'better-sqlite3';
type SqliteDb = Database.Database;
type DbRow = Record<string, unknown>;
export function migratePlugins(db: SqliteDb): void {
db.exec(`
CREATE TABLE IF NOT EXISTS installed_plugins (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
version TEXT NOT NULL,
source_kind TEXT NOT NULL,
source TEXT NOT NULL,
pinned_ref TEXT,
source_digest TEXT,
source_marketplace_id TEXT,
source_marketplace_entry_name TEXT,
source_marketplace_entry_version TEXT,
marketplace_trust TEXT,
resolved_source TEXT,
resolved_ref TEXT,
manifest_digest TEXT,
archive_integrity TEXT,
trust TEXT NOT NULL,
capabilities_granted TEXT NOT NULL,
manifest_json TEXT NOT NULL,
fs_path TEXT NOT NULL,
installed_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_installed_plugins_source_kind
ON installed_plugins(source_kind);
CREATE TABLE IF NOT EXISTS plugin_marketplaces (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
spec_version TEXT NOT NULL DEFAULT '1.0.0',
version TEXT NOT NULL DEFAULT '0.0.0',
trust TEXT NOT NULL,
manifest_json TEXT NOT NULL,
added_at INTEGER NOT NULL,
refreshed_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS applied_plugin_snapshots (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
conversation_id TEXT,
run_id TEXT,
plugin_id TEXT NOT NULL,
plugin_spec_version TEXT NOT NULL DEFAULT '1.0.0',
plugin_version TEXT NOT NULL,
manifest_source_digest TEXT NOT NULL,
source_marketplace_id TEXT,
source_marketplace_entry_name TEXT,
source_marketplace_entry_version TEXT,
marketplace_trust TEXT,
resolved_source TEXT,
resolved_ref TEXT,
archive_integrity TEXT,
pinned_ref TEXT,
task_kind TEXT NOT NULL,
inputs_json TEXT NOT NULL,
resolved_context_json TEXT NOT NULL,
pipeline_json TEXT,
genui_surfaces_json TEXT NOT NULL DEFAULT '[]',
capabilities_granted TEXT NOT NULL,
capabilities_required TEXT NOT NULL DEFAULT '[]',
assets_staged_json TEXT NOT NULL,
connectors_required_json TEXT NOT NULL DEFAULT '[]',
connectors_resolved_json TEXT NOT NULL DEFAULT '[]',
mcp_servers_json TEXT NOT NULL DEFAULT '[]',
plugin_title TEXT,
plugin_description TEXT,
query_text TEXT,
status TEXT NOT NULL DEFAULT 'fresh',
applied_at INTEGER NOT NULL,
expires_at INTEGER,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshots_project ON applied_plugin_snapshots(project_id);
CREATE INDEX IF NOT EXISTS idx_snapshots_run ON applied_plugin_snapshots(run_id);
CREATE INDEX IF NOT EXISTS idx_snapshots_plugin ON applied_plugin_snapshots(plugin_id, plugin_version);
-- §10.2 devloop audit + per-iteration billing surface.
-- run_id is a free string today (in-memory runs, no FK target).
CREATE TABLE IF NOT EXISTS run_devloop_iterations (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
stage_id TEXT NOT NULL,
iteration INTEGER NOT NULL,
artifact_diff_summary TEXT,
critique_summary TEXT,
tokens_used INTEGER,
ended_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_devloop_run ON run_devloop_iterations(run_id);
CREATE INDEX IF NOT EXISTS idx_devloop_run_stage ON run_devloop_iterations(run_id, stage_id);
-- §10.3 GenUI surface persisted state. The lookup rules in §10.3.3
-- read this table at run / conversation / project tier; F8 enforces
-- the cross-conversation cache hit on a second oauth-prompt.
-- conversation_id / run_id are stored as plain TEXT (no FK) because
-- runs are in-memory; conversation FK is set up by the daemon's
-- existing migrations and we don't want to fail on legacy DBs that
-- predate it. plugin_snapshot_id is a FK to applied_plugin_snapshots.
CREATE TABLE IF NOT EXISTS genui_surfaces (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
conversation_id TEXT,
run_id TEXT,
plugin_snapshot_id TEXT NOT NULL,
surface_id TEXT NOT NULL,
kind TEXT NOT NULL,
persist TEXT NOT NULL,
schema_digest TEXT,
value_json TEXT,
status TEXT NOT NULL,
responded_by TEXT,
requested_at INTEGER NOT NULL,
responded_at INTEGER,
expires_at INTEGER,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (plugin_snapshot_id) REFERENCES applied_plugin_snapshots(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_genui_proj_surface ON genui_surfaces(project_id, surface_id);
CREATE INDEX IF NOT EXISTS idx_genui_conv_surface ON genui_surfaces(conversation_id, surface_id);
CREATE INDEX IF NOT EXISTS idx_genui_run ON genui_surfaces(run_id);
`);
const marketplaceCols = db.prepare(`PRAGMA table_info(plugin_marketplaces)`).all() as DbRow[];
if (!marketplaceCols.some((c) => c['name'] === 'spec_version')) {
db.exec(`ALTER TABLE plugin_marketplaces ADD COLUMN spec_version TEXT NOT NULL DEFAULT '1.0.0'`);
}
if (!marketplaceCols.some((c) => c['name'] === 'version')) {
db.exec(`ALTER TABLE plugin_marketplaces ADD COLUMN version TEXT NOT NULL DEFAULT '0.0.0'`);
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_marketplaces_version ON plugin_marketplaces(version)`);
const installedCols = db.prepare(`PRAGMA table_info(installed_plugins)`).all() as DbRow[];
for (const [name, ddl] of [
['source_marketplace_entry_name', `ALTER TABLE installed_plugins ADD COLUMN source_marketplace_entry_name TEXT`],
['source_marketplace_entry_version', `ALTER TABLE installed_plugins ADD COLUMN source_marketplace_entry_version TEXT`],
['marketplace_trust', `ALTER TABLE installed_plugins ADD COLUMN marketplace_trust TEXT`],
['resolved_source', `ALTER TABLE installed_plugins ADD COLUMN resolved_source TEXT`],
['resolved_ref', `ALTER TABLE installed_plugins ADD COLUMN resolved_ref TEXT`],
['manifest_digest', `ALTER TABLE installed_plugins ADD COLUMN manifest_digest TEXT`],
['archive_integrity', `ALTER TABLE installed_plugins ADD COLUMN archive_integrity TEXT`],
] as const) {
if (!installedCols.some((c) => c['name'] === name)) db.exec(ddl);
}
const snapshotCols = db.prepare(`PRAGMA table_info(applied_plugin_snapshots)`).all() as DbRow[];
if (!snapshotCols.some((c) => c['name'] === 'plugin_spec_version')) {
db.exec(`ALTER TABLE applied_plugin_snapshots ADD COLUMN plugin_spec_version TEXT NOT NULL DEFAULT '1.0.0'`);
}
for (const [name, ddl] of [
['source_marketplace_entry_name', `ALTER TABLE applied_plugin_snapshots ADD COLUMN source_marketplace_entry_name TEXT`],
['source_marketplace_entry_version', `ALTER TABLE applied_plugin_snapshots ADD COLUMN source_marketplace_entry_version TEXT`],
['marketplace_trust', `ALTER TABLE applied_plugin_snapshots ADD COLUMN marketplace_trust TEXT`],
['resolved_source', `ALTER TABLE applied_plugin_snapshots ADD COLUMN resolved_source TEXT`],
['resolved_ref', `ALTER TABLE applied_plugin_snapshots ADD COLUMN resolved_ref TEXT`],
['archive_integrity', `ALTER TABLE applied_plugin_snapshots ADD COLUMN archive_integrity TEXT`],
] as const) {
if (!snapshotCols.some((c) => c['name'] === name)) db.exec(ddl);
}
// Back-reference columns. SQLite has no IF NOT EXISTS for ALTER; check
// pragma_table_info first. Mirrors the upstream pattern in db.ts.
const projectCols = db.prepare(`PRAGMA table_info(projects)`).all() as DbRow[];
if (!projectCols.some((c) => c['name'] === 'applied_plugin_snapshot_id')) {
db.exec(`ALTER TABLE projects ADD COLUMN applied_plugin_snapshot_id TEXT`);
}
const conversationCols = db.prepare(`PRAGMA table_info(conversations)`).all() as DbRow[];
if (!conversationCols.some((c) => c['name'] === 'applied_plugin_snapshot_id')) {
db.exec(`ALTER TABLE conversations ADD COLUMN applied_plugin_snapshot_id TEXT`);
}
}

View file

@ -0,0 +1,109 @@
// Pipeline runner — bridges the pure pipeline scheduler in `pipeline.ts`
// onto a live run's SSE event stream and the `genui_surfaces` cache.
//
// Plan §3.A7 / spec §10.1 / §10.2. Today the agent loop owns the actual
// stage execution; this runner is the thin wrapper that:
//
// - emits `pipeline_stage_started` / `pipeline_stage_completed`
// events through the run service so SSE / ND-JSON consumers see the
// stage timeline,
// - persists each iteration into `run_devloop_iterations`,
// - asks `requestOrReuseSurface()` for the matching GenUI surface
// before the stage runner fires (auto-derived `__auto_connector_*`
// prompts always go through the cross-conversation cache so a
// repeat-OAuth never re-broadcasts).
//
// The stage runner itself is supplied by the caller. In v1 the daemon
// passes a stub that only records iteration counts; later phases can
// plug in critique-theater scoring, build-test signals, etc. without
// changing the public contract.
import type Database from 'better-sqlite3';
import type {
AppliedPluginSnapshot,
GenUISurfaceSpec,
PluginPipeline,
} from '@open-design/contracts';
import { runPipeline, type PipelineEnv, type PipelineEventSink, type StageOutcomeRecord, type StageRunner } from './pipeline.js';
import { requestOrReuseSurface } from '../genui/registry.js';
import type { GenUIEventSink } from '../genui/events.js';
type SqliteDb = Database.Database;
export interface PipelineRunnerInput {
db: SqliteDb;
runId: string;
projectId: string;
conversationId?: string | null | undefined;
snapshot: AppliedPluginSnapshot;
pipeline: PluginPipeline;
env: PipelineEnv;
// The actual stage worker. Returns signals (`critique.score`,
// `iterations`, …) that drive `until` evaluation. Apps inject the
// real LLM-driven worker; tests pass a deterministic stub.
runStage: StageRunner;
// Pluggable event sinks. The runs service injects the SSE forwarder
// for both pipeline + genui events; tests pass an array.push closure.
emitPipeline?: PipelineEventSink | undefined;
emitGenui?: GenUIEventSink | undefined;
}
// Walk the pipeline + GenUI surfaces. Returns the per-stage outcomes
// (iteration count + convergence) so the run service can decide whether
// the run finished cleanly. The caller already started the run; we just
// drive its event stream.
export async function runPipelineForRun(
input: PipelineRunnerInput,
): Promise<StageOutcomeRecord[]> {
// Pre-stage GenUI: surfaces with no `trigger.stageId` get raised once
// up-front (typical example: the auto-derived `__auto_connector_*`
// oauth-prompts that the apply path created when a required connector
// was not yet connected). The cache short-circuit here is what makes
// e2e-5 pass: a second conversation in the same project skips the
// broadcast.
const surfaces = input.snapshot.genuiSurfaces ?? [];
for (const surface of surfaces) {
if (surface.trigger?.stageId) continue;
raiseSurface(input, surface);
}
// Wrap the user-supplied stage runner so we can intercept stage entry
// for stage-bound surface raising before delegating to the real
// worker.
const wrapped: StageRunner = async ({ stage, iteration, snapshot }) => {
if (iteration === 0) {
for (const surface of surfaces) {
if (surface.trigger?.stageId === stage.id) raiseSurface(input, surface);
}
}
return input.runStage({ stage, iteration, snapshot });
};
return runPipeline({
db: input.db,
runId: input.runId,
snapshot: input.snapshot,
pipeline: input.pipeline,
env: input.env,
runStage: wrapped,
...(input.emitPipeline ? { emit: input.emitPipeline } : {}),
});
}
function raiseSurface(input: PipelineRunnerInput, surface: GenUISurfaceSpec): void {
try {
requestOrReuseSurface(input.db, {
projectId: input.projectId,
conversationId: input.conversationId ?? null,
runId: input.runId,
pluginSnapshotId: input.snapshot.snapshotId,
surface,
...(input.emitGenui ? { emit: input.emitGenui } : {}),
});
} catch (err) {
// Surface lookups should never crash the pipeline. The error path is
// surfaced to the run as a normal SSE 'error' event by the caller.
// eslint-disable-next-line no-console
console.warn(`[plugins] failed to raise surface ${surface.id}: ${(err as Error).message}`);
}
}

View file

@ -0,0 +1,253 @@
// Plugin pipeline scheduler + devloop driver (spec §10.1 / §10.2).
//
// The scheduler is "headless": it walks the manifest pipeline stage by
// stage, asks the caller-supplied `runStage()` to actually do the work,
// then evaluates each stage's `until` against returned signals to decide
// whether to advance or loop. The devloop ceiling
// (`OD_MAX_DEVLOOP_ITERATIONS`, default 10) is enforced here so a buggy
// plugin cannot burn provider quota in an infinite loop.
//
// Side effects:
//
// - SQLite write to `run_devloop_iterations` after every iteration via
// `recordIteration()`. This module is the sole writer of that table.
// - Optional event sink (`PipelineEventSink`) emits
// `pipeline_stage_started` / `pipeline_stage_completed` events. The
// daemon wires this to `runs.emit(run, ...)`; tests pass an in-memory
// recorder.
//
// The function is exported as a generator-like async iterator so callers
// can stream stage events to SSE without buffering the whole run. Tests
// can drain it into an array.
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import type {
AppliedPluginSnapshot,
PluginPipeline,
PluginPipelineStageEvent,
PipelineStage,
} from '@open-design/contracts';
import {
buildPipelineStageCompletedEvent,
buildPipelineStageStartedEvent,
} from '../genui/events.js';
import { evaluateUntil, parseUntil, type UntilSignals } from './until.js';
type SqliteDb = Database.Database;
export interface PipelineEnv {
maxIterations: number; // OD_MAX_DEVLOOP_ITERATIONS
}
export interface StageRunOutcome {
signals?: UntilSignals | undefined;
artifactDiffSummary?: string | null | undefined;
critiqueSummary?: string | null | undefined;
tokensUsed?: number | null | undefined;
}
// Caller-supplied stage runner. Receives the stage spec + the iteration
// counter (0-indexed). Returns signals the `until` evaluator can read
// plus optional audit metadata for `run_devloop_iterations`.
export type StageRunner = (args: {
stage: PipelineStage;
iteration: number;
snapshot: AppliedPluginSnapshot;
}) => Promise<StageRunOutcome> | StageRunOutcome;
export type PipelineEventSink = (event: PluginPipelineStageEvent) => void;
export interface RunPipelineInput {
db: SqliteDb;
runId: string;
snapshot: AppliedPluginSnapshot;
pipeline: PluginPipeline;
env: PipelineEnv;
runStage: StageRunner;
emit?: PipelineEventSink | undefined;
}
export interface StageOutcomeRecord {
stageId: string;
iterations: number;
converged: boolean;
// The reason the loop terminated: 'until-satisfied' | 'iteration-cap'
// | 'no-loop' (single-shot stage).
termination: 'until-satisfied' | 'iteration-cap' | 'no-loop';
}
export class PipelineConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'PipelineConfigError';
}
}
// Run an entire pipeline. Returns one `StageOutcomeRecord` per stage in
// pipeline order. Throws on config errors (`repeat: true` without
// `until`, unparseable `until`, etc.). Iteration cap hits are reported
// as a non-converged outcome and let the caller decide whether to
// continue or fail the run.
export async function runPipeline(input: RunPipelineInput): Promise<StageOutcomeRecord[]> {
const out: StageOutcomeRecord[] = [];
for (const stage of input.pipeline.stages) {
out.push(await runStageWithDevloop(stage, input));
}
return out;
}
async function runStageWithDevloop(
stage: PipelineStage,
input: RunPipelineInput,
): Promise<StageOutcomeRecord> {
const repeat = stage.repeat === true;
if (repeat && !stage.until) {
throw new PipelineConfigError(
`stage "${stage.id}" sets repeat:true but no until expression — refusing to schedule`,
);
}
const expression = stage.until ? parseUntilOrThrow(stage.until, stage.id) : null;
const max = repeat ? Math.max(1, input.env.maxIterations) : 1;
let iteration = 0;
let converged = !repeat;
let lastSignals: UntilSignals = { iterations: 0 };
while (iteration < max) {
input.emit?.(
buildPipelineStageStartedEvent({
runId: input.runId,
snapshotId: input.snapshot.snapshotId,
stageId: stage.id,
iteration,
}),
);
const outcome = await Promise.resolve(
input.runStage({ stage, iteration, snapshot: input.snapshot }),
);
iteration += 1;
const merged: UntilSignals = {
...(outcome.signals ?? {}),
iterations: iteration,
};
lastSignals = merged;
let stageConverged = !repeat;
if (expression) {
stageConverged = evaluateUntil(expression, merged).satisfied;
}
recordIteration(input.db, {
runId: input.runId,
stageId: stage.id,
iteration,
artifactDiffSummary: outcome.artifactDiffSummary ?? null,
critiqueSummary: outcome.critiqueSummary ?? null,
tokensUsed: outcome.tokensUsed ?? null,
});
input.emit?.(
buildPipelineStageCompletedEvent({
runId: input.runId,
snapshotId: input.snapshot.snapshotId,
stageId: stage.id,
iteration: iteration - 1,
converged: stageConverged,
...(outcome.artifactDiffSummary
? { diffSummary: outcome.artifactDiffSummary }
: {}),
}),
);
if (stageConverged) {
converged = true;
return {
stageId: stage.id,
iterations: iteration,
converged,
termination: repeat ? 'until-satisfied' : 'no-loop',
};
}
}
void lastSignals;
return {
stageId: stage.id,
iterations: iteration,
converged: false,
termination: 'iteration-cap',
};
}
function parseUntilOrThrow(source: string, stageId: string): ReturnType<typeof parseUntil> {
try {
return parseUntil(source);
} catch (err) {
throw new PipelineConfigError(
`stage "${stageId}" until expression rejected: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
export interface DevloopIterationRow {
id: string;
runId: string;
stageId: string;
iteration: number;
artifactDiffSummary: string | null;
critiqueSummary: string | null;
tokensUsed: number | null;
endedAt: number;
}
interface RecordIterationInput {
runId: string;
stageId: string;
iteration: number;
artifactDiffSummary: string | null;
critiqueSummary: string | null;
tokensUsed: number | null;
}
export function recordIteration(db: SqliteDb, input: RecordIterationInput): DevloopIterationRow {
const id = randomUUID();
const endedAt = Date.now();
db.prepare(
`INSERT INTO run_devloop_iterations
(id, run_id, stage_id, iteration, artifact_diff_summary, critique_summary, tokens_used, ended_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id,
input.runId,
input.stageId,
input.iteration,
input.artifactDiffSummary,
input.critiqueSummary,
input.tokensUsed,
endedAt,
);
return {
id,
runId: input.runId,
stageId: input.stageId,
iteration: input.iteration,
artifactDiffSummary: input.artifactDiffSummary,
critiqueSummary: input.critiqueSummary,
tokensUsed: input.tokensUsed,
endedAt,
};
}
export function listIterationsForRun(db: SqliteDb, runId: string): DevloopIterationRow[] {
const rows = db.prepare(
`SELECT * FROM run_devloop_iterations WHERE run_id = ? ORDER BY iteration ASC, ended_at ASC`,
).all(runId) as Array<{
id: string; run_id: string; stage_id: string; iteration: number;
artifact_diff_summary: string | null; critique_summary: string | null;
tokens_used: number | null; ended_at: number;
}>;
return rows.map((r) => ({
id: r.id,
runId: r.run_id,
stageId: r.stage_id,
iteration: r.iteration,
artifactDiffSummary: r.artifact_diff_summary,
critiqueSummary: r.critique_summary,
tokensUsed: r.tokens_used,
endedAt: r.ended_at,
}));
}

View file

@ -0,0 +1,284 @@
// Phase 4 / spec §14.1 — `od plugin publish --to <catalog>` PR-template launcher.
//
// Produces a deep-link URL into the target catalog's "submit" form
// (or its repo PR template) so an author can land their plugin on
// every catalog the spec lists in §14 without remembering the URL
// scheme of each one. We never mutate the catalog directly — the
// author still goes through the upstream review flow.
//
// Targets (must stay in sync with spec §14):
// - anthropics-skills → anthropics/skills
// - awesome-agent-skills → VoltAgent/awesome-agent-skills
// - clawhub → openclaw/clawhub
// - skills-sh → skills.sh discovery hint
// - open-design → open-design/plugin-registry
//
// The function is pure: it accepts the plugin's metadata and returns
// the catalog target description. The CLI is the side-effect-bearing
// caller (prints the URL or auto-opens via `open`/`xdg-open`).
export type PublishCatalog =
| 'anthropics-skills'
| 'awesome-agent-skills'
| 'clawhub'
| 'skills-sh'
| 'open-design';
export interface PublishMetadata {
// Plugin name + version come from the manifest. The repo URL is the
// upstream the author published the plugin under (github.com/owner/repo).
pluginId: string;
pluginVersion: string;
pluginTitle?: string;
pluginDescription?: string;
repoUrl?: string;
}
export interface PublishLink {
catalog: PublishCatalog;
// Human-readable name of the catalog ("anthropics/skills", etc.).
catalogLabel: string;
// The URL the author lands on. Either a "create issue / new PR"
// wizard with the title + body pre-filled, or the catalog's contact
// page when the catalog has no submission form.
url: string;
// Optional pre-rendered PR body the author can copy if the URL's
// query string strips it. Always supplied; UI / CLI display it.
prBody: string;
}
export interface MarketplaceJsonManifest {
specVersion: string;
name: string;
version: string;
generatedAt?: string;
plugins: MarketplaceJsonEntry[];
[key: string]: unknown;
}
export interface MarketplaceJsonEntry {
name: string;
source: string;
version: string;
title?: string;
description?: string;
publisher?: {
name?: string;
github?: string;
url?: string;
};
homepage?: string;
repo?: string;
[key: string]: unknown;
}
export interface MarketplaceJsonPublishOutcome {
manifest: MarketplaceJsonManifest;
entry: MarketplaceJsonEntry;
inserted: boolean;
}
export class PublishError extends Error {
constructor(message: string) {
super(message);
this.name = 'PublishError';
}
}
const KNOWN_TARGETS = new Set<PublishCatalog>([
'anthropics-skills',
'awesome-agent-skills',
'clawhub',
'skills-sh',
'open-design',
]);
export function buildPublishLink(args: {
catalog: PublishCatalog;
meta: PublishMetadata;
}): PublishLink {
if (!KNOWN_TARGETS.has(args.catalog)) {
throw new PublishError(`unknown catalog: ${args.catalog}. Accepted: ${Array.from(KNOWN_TARGETS).join(', ')}`);
}
const m = args.meta;
const title = `Add ${m.pluginTitle ?? m.pluginId}`;
const body = renderPrBody(m);
switch (args.catalog) {
case 'anthropics-skills': {
const url = newIssueUrl('anthropics/skills', title, body);
return { catalog: args.catalog, catalogLabel: 'anthropics/skills', url, prBody: body };
}
case 'awesome-agent-skills': {
const url = newIssueUrl('VoltAgent/awesome-agent-skills', title, body);
return { catalog: args.catalog, catalogLabel: 'VoltAgent/awesome-agent-skills', url, prBody: body };
}
case 'clawhub': {
const url = newIssueUrl('openclaw/clawhub', title, body);
return { catalog: args.catalog, catalogLabel: 'openclaw/clawhub', url, prBody: body };
}
case 'skills-sh': {
// skills.sh autodiscovers via `npx skills add owner/repo` so a
// first-time submission is a documentation step, not a PR. Point
// the author at the canonical add command + the docs page.
const repo = m.repoUrl?.replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/i, '') ?? 'owner/repo';
return {
catalog: args.catalog,
catalogLabel: 'skills.sh',
url: 'https://skills.sh/',
prBody: [
body,
'',
'## Submission steps',
'',
`1. Push the plugin repo to https://github.com/${repo}`,
`2. Run \`npx skills add ${repo}\` once locally to seed the catalog index.`,
'3. Verify the entry appears at https://skills.sh/ within ~24 hours.',
].join('\n'),
};
}
case 'open-design': {
const bodyWithRegistry = [
body,
'',
'## Open Design registry entry',
'',
'- Target path: `community/<vendor>/<plugin-name>/open-design.json`',
'- Generated index: `open-design-marketplace.json`',
'- Required checks: `od plugin validate`, `od plugin pack`, integrity digest, preview smoke.',
].join('\n');
const url = newIssueUrl('open-design/plugin-registry', title, bodyWithRegistry);
return {
catalog: args.catalog,
catalogLabel: 'open-design/plugin-registry',
url,
prBody: bodyWithRegistry,
};
}
}
// Unreachable; keeps the compiler happy.
throw new PublishError(`unhandled catalog: ${String(args.catalog)}`);
}
export function buildMarketplaceJsonEntry(meta: PublishMetadata): MarketplaceJsonEntry {
if (!meta.pluginId.includes('/')) {
throw new PublishError('marketplace-json publish requires a stable namespaced id: vendor/plugin-name');
}
if (!meta.repoUrl) {
throw new PublishError('marketplace-json publish requires meta.repoUrl');
}
const parsedRepo = parseGithubRepo(meta.repoUrl);
const entry: MarketplaceJsonEntry = {
name: meta.pluginId,
source: parsedRepo.source,
version: meta.pluginVersion,
repo: meta.repoUrl,
homepage: meta.repoUrl,
publisher: {
name: parsedRepo.owner,
github: parsedRepo.owner,
url: `https://github.com/${parsedRepo.owner}`,
},
};
if (meta.pluginTitle) entry.title = meta.pluginTitle;
if (meta.pluginDescription) entry.description = meta.pluginDescription;
return entry;
}
export function upsertMarketplaceJsonEntry(args: {
manifest?: Partial<MarketplaceJsonManifest> | null;
meta: PublishMetadata;
generatedAt?: string;
}): MarketplaceJsonPublishOutcome {
const entry = buildMarketplaceJsonEntry(args.meta);
const existing = args.manifest ?? {};
const plugins = Array.isArray(existing.plugins) ? existing.plugins : [];
let inserted = true;
const nextPlugins = plugins.map((plugin) => {
if (plugin?.name === entry.name) {
inserted = false;
return {
...plugin,
...entry,
};
}
return plugin;
});
if (inserted) {
nextPlugins.push(entry);
}
nextPlugins.sort((a, b) => String(a.name).localeCompare(String(b.name)));
const manifest: MarketplaceJsonManifest = {
...existing,
specVersion: typeof existing.specVersion === 'string' ? existing.specVersion : '1.0.0',
name: typeof existing.name === 'string' ? existing.name : 'open-design-marketplace',
version: typeof existing.version === 'string' ? existing.version : '1.0.0',
generatedAt: args.generatedAt ?? new Date().toISOString(),
plugins: nextPlugins,
};
return { manifest, entry, inserted };
}
function newIssueUrl(repo: string, title: string, body: string): string {
const params = new URLSearchParams();
params.set('title', title);
params.set('body', body);
return `https://github.com/${repo}/issues/new?${params.toString()}`;
}
function parseGithubRepo(repoUrl: string): { owner: string; repo: string; source: string } {
let url: URL;
try {
url = new URL(repoUrl);
} catch {
throw new PublishError(`unsupported repo URL: ${repoUrl}`);
}
if (url.hostname.toLowerCase() !== 'github.com') {
throw new PublishError('marketplace-json publish currently requires a github.com repo URL');
}
const parts = url.pathname.split('/').filter(Boolean);
const owner = parts[0];
const repo = parts[1]?.replace(/\.git$/i, '');
if (!owner || !repo) {
throw new PublishError(`unsupported GitHub repo URL: ${repoUrl}`);
}
if (parts[2] === 'tree' && parts[3]) {
const ref = parts[3];
const subpath = parts.slice(4).join('/');
return {
owner,
repo,
source: `github:${owner}/${repo}@${ref}${subpath ? `/${subpath}` : ''}`,
};
}
return {
owner,
repo,
source: `github:${owner}/${repo}`,
};
}
function renderPrBody(m: PublishMetadata): string {
const lines: string[] = [];
lines.push(`## ${m.pluginTitle ?? m.pluginId}`);
if (m.pluginDescription) {
lines.push('');
lines.push(m.pluginDescription);
}
lines.push('');
lines.push('## Provenance');
lines.push('');
lines.push(`- name: \`${m.pluginId}\``);
lines.push(`- version: \`${m.pluginVersion}\``);
if (m.repoUrl) lines.push(`- repository: ${m.repoUrl}`);
lines.push('');
lines.push('## Compatibility');
lines.push('');
lines.push('- Ships `SKILL.md` (canonical agent skill anchor).');
lines.push('- Ships `open-design.json` sidecar (additive Open Design metadata).');
lines.push('');
lines.push('Generated by `od plugin publish` — see https://open-design.ai/docs/plugins-spec.md.');
return lines.join('\n');
}
export const PUBLISH_TARGETS = Array.from(KNOWN_TARGETS);

View file

@ -0,0 +1,297 @@
// Plugin registry. Phase 1 scope:
//
// - Scans `<daemonDataDir>/plugins/<id>/` (the OD-canonical install root) for
// manifest folders.
// - Resolves a plugin folder into either an `open-design.json`-anchored
// manifest or a synthesized one derived from `SKILL.md` /
// `.claude-plugin/plugin.json` (per spec §3 compatibility matrix).
// - Persists discovered records into the `installed_plugins` SQLite row so
// subsequent CLI / HTTP calls can read without rescanning the FS.
//
// Phase 2A will add the project-cwd tier and the legacy SKILL.md tiers; we
// keep this module narrow today so the loader / installer split stays
// honest. Adding more tiers is a pure data-source change and never a
// schema migration.
import path from 'node:path';
import fs from 'node:fs';
import { promises as fsp } from 'node:fs';
import {
adaptAgentSkill,
adaptClaudePlugin,
mergeManifests,
parseManifest,
validateSafe,
type ManifestParseResult,
} from '@open-design/plugin-runtime';
import type {
InstalledPluginRecord,
MarketplaceTrust,
PluginManifest,
PluginSourceKind,
TrustTier,
} from '@open-design/contracts';
import type Database from 'better-sqlite3';
type SqliteDb = Database.Database;
type DbRow = Record<string, unknown>;
export interface RegistryRoots {
// User-installed plugin bytes. Production passes a daemon data-root-derived
// value; tests can point this at a sandbox.
userPluginsRoot: string;
}
export function registryRootsForDataDir(dataDir: string): RegistryRoots {
return {
userPluginsRoot: path.join(dataDir, 'plugins'),
};
}
export function defaultRegistryRoots(): RegistryRoots {
return registryRootsForDataDir(path.resolve(process.env.OD_DATA_DIR ?? path.join(process.cwd(), '.od')));
}
export interface ScannedPlugin {
record: InstalledPluginRecord;
warnings: string[];
}
export interface ResolveOptions {
// The on-disk folder. Used for both reading and computing the manifest's
// sourceDigest. Phase 2A swaps this to the registry's discovered fsPath.
folder: string;
folderId: string;
sourceKind?: PluginSourceKind;
source?: string;
pinnedRef?: string;
trust?: TrustTier;
capabilitiesGranted?: string[];
sourceMarketplaceId?: string;
sourceMarketplaceEntryName?: string;
sourceMarketplaceEntryVersion?: string;
marketplaceTrust?: MarketplaceTrust;
resolvedSource?: string;
resolvedRef?: string;
manifestDigest?: string;
archiveIntegrity?: string;
}
export interface ResolveOutcome {
ok: true;
record: InstalledPluginRecord;
warnings: string[];
}
export interface ResolveFailure {
ok: false;
errors: string[];
warnings: string[];
}
export type ResolveResult = ResolveOutcome | ResolveFailure;
// Resolve a single plugin folder into a typed InstalledPluginRecord. Pure
// FS read, no SQLite write — the installer module is the only writer.
export async function resolvePluginFolder(opts: ResolveOptions): Promise<ResolveResult> {
const warnings: string[] = [];
const errors: string[] = [];
const folder = opts.folder;
let stats: fs.Stats;
try {
stats = await fsp.stat(folder);
} catch (err) {
return { ok: false, errors: [`Plugin folder not found: ${folder} (${(err as Error).message})`], warnings };
}
if (!stats.isDirectory()) {
return { ok: false, errors: [`Plugin path is not a directory: ${folder}`], warnings };
}
const sidecarPath = path.join(folder, 'open-design.json');
const skillPath = path.join(folder, 'SKILL.md');
const claudePath = path.join(folder, '.claude-plugin', 'plugin.json');
let sidecar: PluginManifest | undefined;
if (fs.existsSync(sidecarPath)) {
const rawSidecar = await fsp.readFile(sidecarPath, 'utf8');
const parsed: ManifestParseResult = parseManifest(rawSidecar);
if (!parsed.ok) {
errors.push(...parsed.errors.map((e) => `open-design.json: ${e}`));
} else {
sidecar = parsed.manifest;
warnings.push(...parsed.warnings);
}
}
const adapters: PluginManifest[] = [];
if (fs.existsSync(skillPath)) {
const raw = await fsp.readFile(skillPath, 'utf8');
const adapted = adaptAgentSkill(raw, { folderId: opts.folderId });
adapters.push(adapted.manifest);
warnings.push(...adapted.warnings);
}
if (fs.existsSync(claudePath)) {
const raw = await fsp.readFile(claudePath, 'utf8');
const adapted = adaptClaudePlugin(raw, { folderId: opts.folderId });
adapters.push(adapted.manifest);
warnings.push(...adapted.warnings);
}
if (!sidecar && adapters.length === 0) {
return {
ok: false,
errors: [...errors, `Plugin folder contains no SKILL.md, no .claude-plugin/plugin.json, and no open-design.json: ${folder}`],
warnings,
};
}
const manifest = mergeManifests({ sidecar, adapters });
const validation = validateSafe(manifest);
warnings.push(...validation.warnings);
if (!validation.ok) {
return { ok: false, errors: [...errors, ...validation.errors], warnings };
}
if (errors.length > 0) {
return { ok: false, errors, warnings };
}
const now = Date.now();
// The manifest name wins (spec §5.1: plugin id IS the manifest name). The
// folderId fallback only kicks in when an adapter-only manifest forgot to
// set name, which Zod validation already rejects.
const id = (manifest.name ?? opts.folderId).toLowerCase();
const record: InstalledPluginRecord = {
id,
title: manifest.title ?? manifest.name,
version: manifest.version,
sourceKind: opts.sourceKind ?? 'local',
source: opts.source ?? folder,
pinnedRef: opts.pinnedRef,
sourceMarketplaceId: opts.sourceMarketplaceId,
sourceMarketplaceEntryName: opts.sourceMarketplaceEntryName,
sourceMarketplaceEntryVersion: opts.sourceMarketplaceEntryVersion,
marketplaceTrust: opts.marketplaceTrust,
resolvedSource: opts.resolvedSource,
resolvedRef: opts.resolvedRef,
manifestDigest: opts.manifestDigest,
archiveIntegrity: opts.archiveIntegrity,
trust: opts.trust ?? 'restricted',
capabilitiesGranted: opts.capabilitiesGranted ?? defaultRestrictedCapabilities(),
manifest,
fsPath: folder,
installedAt: now,
updatedAt: now,
};
return { ok: true, record, warnings };
}
function defaultRestrictedCapabilities(): string[] {
// Spec §5.3: restricted plugins start with prompt:inject only. Apply-time
// grants land additional capabilities on the snapshot, never here.
return ['prompt:inject'];
}
// Map a SQLite row back into an InstalledPluginRecord. Centralized so every
// reader gets the same JSON parsing contract.
export function rowToInstalledPlugin(row: DbRow): InstalledPluginRecord {
const manifestJson = typeof row['manifest_json'] === 'string' ? (row['manifest_json'] as string) : '{}';
const manifest = JSON.parse(manifestJson) as PluginManifest;
const capabilitiesJson = typeof row['capabilities_granted'] === 'string' ? (row['capabilities_granted'] as string) : '[]';
const capabilities = JSON.parse(capabilitiesJson) as string[];
return {
id: String(row['id']),
title: String(row['title']),
version: String(row['version']),
sourceKind: row['source_kind'] as PluginSourceKind,
source: String(row['source']),
pinnedRef: row['pinned_ref'] != null ? String(row['pinned_ref']) : undefined,
sourceDigest: row['source_digest'] != null ? String(row['source_digest']) : undefined,
sourceMarketplaceId: row['source_marketplace_id'] != null ? String(row['source_marketplace_id']) : undefined,
sourceMarketplaceEntryName: row['source_marketplace_entry_name'] != null ? String(row['source_marketplace_entry_name']) : undefined,
sourceMarketplaceEntryVersion: row['source_marketplace_entry_version'] != null ? String(row['source_marketplace_entry_version']) : undefined,
marketplaceTrust: row['marketplace_trust'] != null ? row['marketplace_trust'] as MarketplaceTrust : undefined,
resolvedSource: row['resolved_source'] != null ? String(row['resolved_source']) : undefined,
resolvedRef: row['resolved_ref'] != null ? String(row['resolved_ref']) : undefined,
manifestDigest: row['manifest_digest'] != null ? String(row['manifest_digest']) : undefined,
archiveIntegrity: row['archive_integrity'] != null ? String(row['archive_integrity']) : undefined,
trust: row['trust'] as TrustTier,
capabilitiesGranted: Array.isArray(capabilities) ? capabilities : [],
manifest,
fsPath: String(row['fs_path']),
installedAt: Number(row['installed_at']),
updatedAt: Number(row['updated_at']),
};
}
export function listInstalledPlugins(db: SqliteDb): InstalledPluginRecord[] {
const rows = db.prepare(`SELECT * FROM installed_plugins ORDER BY title ASC`).all() as DbRow[];
return rows.map(rowToInstalledPlugin);
}
export function getInstalledPlugin(db: SqliteDb, id: string): InstalledPluginRecord | null {
const row = db.prepare(`SELECT * FROM installed_plugins WHERE id = ?`).get(id) as DbRow | undefined;
return row ? rowToInstalledPlugin(row) : null;
}
export function upsertInstalledPlugin(db: SqliteDb, record: InstalledPluginRecord): void {
db.prepare(`
INSERT INTO installed_plugins (
id, title, version, source_kind, source, pinned_ref, source_digest,
source_marketplace_id, source_marketplace_entry_name,
source_marketplace_entry_version, marketplace_trust, resolved_source,
resolved_ref, manifest_digest, archive_integrity,
trust, capabilities_granted, manifest_json,
fs_path, installed_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
version = excluded.version,
source_kind = excluded.source_kind,
source = excluded.source,
pinned_ref = excluded.pinned_ref,
source_digest = excluded.source_digest,
source_marketplace_id = excluded.source_marketplace_id,
source_marketplace_entry_name = excluded.source_marketplace_entry_name,
source_marketplace_entry_version = excluded.source_marketplace_entry_version,
marketplace_trust = excluded.marketplace_trust,
resolved_source = excluded.resolved_source,
resolved_ref = excluded.resolved_ref,
manifest_digest = excluded.manifest_digest,
archive_integrity = excluded.archive_integrity,
trust = excluded.trust,
capabilities_granted = excluded.capabilities_granted,
manifest_json = excluded.manifest_json,
fs_path = excluded.fs_path,
updated_at = excluded.updated_at
`).run(
record.id,
record.title,
record.version,
record.sourceKind,
record.source,
record.pinnedRef ?? null,
record.sourceDigest ?? null,
record.sourceMarketplaceId ?? null,
record.sourceMarketplaceEntryName ?? null,
record.sourceMarketplaceEntryVersion ?? null,
record.marketplaceTrust ?? null,
record.resolvedSource ?? null,
record.resolvedRef ?? null,
record.manifestDigest ?? null,
record.archiveIntegrity ?? null,
record.trust,
JSON.stringify(record.capabilitiesGranted ?? []),
JSON.stringify(record.manifest),
record.fsPath,
record.installedAt,
record.updatedAt,
);
}
export function deleteInstalledPlugin(db: SqliteDb, id: string): boolean {
const info = db.prepare(`DELETE FROM installed_plugins WHERE id = ?`).run(id);
return info.changes > 0;
}

View file

@ -0,0 +1,359 @@
// Plugin snapshot resolver — wires the pure `applyPlugin()` into the
// daemon's `POST /api/projects` and `POST /api/runs` paths. Spec §8.2.1
// invariant I3: the AppliedPluginSnapshot is the only contract between
// "plugin" and "run". This module owns the side-effect-bearing edges:
//
// 1. Caller supplies `appliedPluginSnapshotId` → look it up, verify it
// isn't stale.
// 2. Caller supplies `pluginId` (+ optional `pluginInputs`,
// `grantCaps`) → run `applyPlugin()` with the live registry,
// persist a `createSnapshot()` row, and return the new snapshot id.
// 3. Neither field present → return `null`; the caller proceeds with
// the legacy non-plugin code path.
//
// Capability gating: when the resolved snapshot is `restricted` and any
// `capabilitiesRequired` is missing from `capabilitiesGranted`, we
// short-circuit with the §9.1 / exit-66 / 409 body. The caller maps the
// returned `error` shape to either an HTTP 409 or a stderr JSON envelope.
//
// This module is the single entry point for both project create and
// run start; all snapshot wiring goes through here so the behavior stays
// deterministic across CLI / desktop / web.
import type Database from 'better-sqlite3';
import type {
AppliedPluginSnapshot,
ApplyResult,
InstalledPluginRecord,
PluginConnectorBinding,
} from '@open-design/contracts';
import {
applyPlugin,
MissingInputError,
type ApplyTrust,
} from './apply.js';
import {
getInstalledPlugin,
} from './registry.js';
import {
createSnapshot,
getSnapshot,
linkSnapshotToConversation,
linkSnapshotToProject,
linkSnapshotToRun,
} from './snapshots.js';
import {
type ConnectorProbe,
} from './connector-gate.js';
import type { RegistryView } from '@open-design/plugin-runtime';
type SqliteDb = Database.Database;
export interface ResolveSnapshotInput {
db: SqliteDb;
body: Record<string, unknown> | null | undefined;
// The project this snapshot will pin to. For the run-create path we
// always know it (the run carries `projectId`). For project-create we
// pass the freshly-inserted project id.
projectId: string;
conversationId?: string | null | undefined;
runId?: string | null | undefined;
// Pluggable for tests; in production these are the daemon's live
// skill / design-system catalogs (server.ts wires them).
registry: RegistryView;
connectorProbe?: ConnectorProbe | undefined;
// Optional active-project DS binding. Forwarded to `applyPlugin` so
// plugins that declared `od.context.designSystem.primary: true` get
// bound to the project's DS at apply time.
activeProjectDesignSystem?: { id: string; title?: string } | undefined;
}
export interface ResolveSnapshotOk {
ok: true;
snapshotId: string;
snapshot: AppliedPluginSnapshot;
applyResult?: ApplyResult;
// Whether this call created a new snapshot (true) or reused an
// explicit `appliedPluginSnapshotId` (false). Used by callers to
// decide when to re-link to a different project / run / conversation.
created: boolean;
}
export interface ResolveSnapshotError {
ok: false;
status: number; // HTTP status to return
exitCode: number; // Matching CLI exit code (§12.4)
body: {
error: {
code: string;
message: string;
data?: Record<string, unknown>;
};
};
}
export type ResolveSnapshotResult = ResolveSnapshotOk | ResolveSnapshotError | null;
// Read the snapshot id that's currently pinned on a project row (if any).
// Returns null when the project is missing or has no snapshot pinned.
// Used by resolvePluginSnapshot's fallback so a plain `POST /api/runs
// { projectId }` reuses the snapshot the user picked at project create
// time — without forcing every caller to re-thread the snapshot id.
function readProjectPinnedSnapshotId(db: SqliteDb, projectId: string): string | null {
try {
const row = db
.prepare(`SELECT applied_plugin_snapshot_id AS id FROM projects WHERE id = ?`)
.get(projectId) as { id?: string | null } | undefined;
const id = row?.id;
return typeof id === 'string' && id.length > 0 ? id : null;
} catch {
return null;
}
}
// Pull plugin-bearing fields off the request body without mutating it.
function pickPluginFields(body: Record<string, unknown> | null | undefined) {
if (!body || typeof body !== 'object') return {};
const pluginId = typeof body.pluginId === 'string' && body.pluginId.trim().length > 0
? body.pluginId.trim()
: undefined;
const snapshotId = typeof body.appliedPluginSnapshotId === 'string'
&& body.appliedPluginSnapshotId.trim().length > 0
? body.appliedPluginSnapshotId.trim()
: undefined;
const pluginInputs =
body.pluginInputs && typeof body.pluginInputs === 'object'
? (body.pluginInputs as Record<string, unknown>)
: body.inputs && typeof body.inputs === 'object'
? (body.inputs as Record<string, unknown>)
: {};
const grantCaps = Array.isArray(body.grantCaps)
? (body.grantCaps as unknown[])
.filter((c): c is string => typeof c === 'string')
: [];
const locale = typeof body.locale === 'string' ? body.locale : undefined;
return { pluginId, snapshotId, pluginInputs, grantCaps, locale };
}
export function resolvePluginSnapshot(input: ResolveSnapshotInput): ResolveSnapshotResult {
const fields = pickPluginFields(input.body);
// If the caller didn't name a plugin / snapshot in the body but a
// snapshot is already pinned to the project (set by a prior project /
// conversation create that ran the plugin), reuse it. This is what
// makes ChatComposer's "start a run" path work after the user picked a
// plugin in NewProjectPanel — the body only carries `projectId`.
if (!fields.pluginId && !fields.snapshotId && input.projectId) {
const pinned = readProjectPinnedSnapshotId(input.db, input.projectId);
if (pinned) {
fields.snapshotId = pinned;
}
}
if (!fields.pluginId && !fields.snapshotId) return null;
// Path 1: explicit snapshot id — look it up and verify status.
if (fields.snapshotId) {
const snapshot = getSnapshot(input.db, fields.snapshotId);
if (!snapshot) {
return {
ok: false,
status: 404,
exitCode: 65,
body: {
error: {
code: 'snapshot-not-found',
message: `Applied plugin snapshot ${fields.snapshotId} not found`,
data: { snapshotId: fields.snapshotId },
},
},
};
}
if (snapshot.status === 'stale') {
return {
ok: false,
status: 409,
exitCode: 72,
body: {
error: {
code: 'snapshot-stale',
message: `Snapshot ${fields.snapshotId} was marked stale; re-apply the plugin or replay the run.`,
data: {
snapshotId: snapshot.snapshotId,
pluginId: snapshot.pluginId,
snapshotVersion: snapshot.pluginVersion,
},
},
},
};
}
return finalizeOk({
input,
snapshot,
created: false,
});
}
// Path 2: pluginId — run apply, persist a new snapshot.
const plugin = getInstalledPlugin(input.db, fields.pluginId!);
if (!plugin) {
return {
ok: false,
status: 404,
exitCode: 65,
body: {
error: {
code: 'plugin-not-found',
message: `Plugin "${fields.pluginId}" is not installed.`,
data: { pluginId: fields.pluginId },
},
},
};
}
let applyComputed;
try {
applyComputed = applyPlugin({
plugin,
inputs: fields.pluginInputs ?? {},
registry: input.registry,
activeProjectDesignSystem: input.activeProjectDesignSystem,
connectorProbe: input.connectorProbe,
locale: fields.locale,
});
} catch (err) {
if (err instanceof MissingInputError) {
return {
ok: false,
status: 422,
exitCode: 67,
body: {
error: {
code: 'missing-input',
message: `Plugin "${fields.pluginId}" is missing required inputs: ${err.fields.join(', ')}.`,
data: { pluginId: fields.pluginId, missing: err.fields },
},
},
};
}
throw err;
}
const result = applyComputed.result;
const trust: ApplyTrust = result.trust;
const grantedSet = new Set([...result.capabilitiesGranted, ...fields.grantCaps]);
const merged = Array.from(grantedSet);
const missing = result.capabilitiesRequired.filter((c) => !grantedSet.has(c));
if (trust === 'restricted' && missing.length > 0) {
return capabilitiesRequiredError({
pluginId: plugin.id,
pluginVersion: plugin.version,
required: result.capabilitiesRequired,
granted: merged,
missing,
});
}
const persisted = createSnapshot(input.db, {
projectId: input.projectId,
conversationId: input.conversationId ?? null,
runId: input.runId ?? null,
pluginId: result.appliedPlugin.pluginId,
pluginSpecVersion: result.appliedPlugin.pluginSpecVersion ?? plugin.manifest.specVersion,
pluginVersion: result.appliedPlugin.pluginVersion,
pluginTitle: result.appliedPlugin.pluginTitle,
pluginDescription: result.appliedPlugin.pluginDescription,
manifestSourceDigest: applyComputed.manifestSourceDigest,
sourceMarketplaceId: result.appliedPlugin.sourceMarketplaceId ?? null,
sourceMarketplaceEntryName: result.appliedPlugin.sourceMarketplaceEntryName ?? null,
sourceMarketplaceEntryVersion: result.appliedPlugin.sourceMarketplaceEntryVersion ?? null,
marketplaceTrust: result.appliedPlugin.marketplaceTrust ?? null,
resolvedSource: result.appliedPlugin.resolvedSource ?? null,
resolvedRef: result.appliedPlugin.resolvedRef ?? null,
archiveIntegrity: result.appliedPlugin.archiveIntegrity ?? null,
pinnedRef: result.appliedPlugin.pinnedRef ?? null,
taskKind: result.appliedPlugin.taskKind,
inputs: result.appliedPlugin.inputs,
resolvedContext: result.appliedPlugin.resolvedContext,
pipeline: result.appliedPlugin.pipeline,
genuiSurfaces: result.appliedPlugin.genuiSurfaces ?? [],
capabilitiesGranted: merged,
capabilitiesRequired: result.capabilitiesRequired,
assetsStaged: result.appliedPlugin.assetsStaged,
connectorsRequired: result.appliedPlugin.connectorsRequired,
connectorsResolved: result.appliedPlugin.connectorsResolved,
mcpServers: result.appliedPlugin.mcpServers,
query: result.query,
});
return finalizeOk({
input,
snapshot: persisted,
applyResult: { ...result, appliedPlugin: persisted },
created: true,
});
}
function finalizeOk(args: {
input: ResolveSnapshotInput;
snapshot: AppliedPluginSnapshot;
applyResult?: ApplyResult;
created: boolean;
}): ResolveSnapshotOk {
// Pin the snapshot to whichever surfaces the caller already knows.
// Order matters: link to project (always) before conversation/run so
// the foreign key is satisfied and `expires_at` clears in one statement.
const { db } = args.input;
const snap = args.snapshot;
if (args.input.projectId) {
linkSnapshotToProject(db, snap.snapshotId, args.input.projectId);
}
if (args.input.conversationId) {
linkSnapshotToConversation(db, snap.snapshotId, args.input.conversationId);
}
if (args.input.runId) {
linkSnapshotToRun(db, snap.snapshotId, args.input.runId);
}
return {
ok: true,
snapshotId: snap.snapshotId,
snapshot: snap,
...(args.applyResult ? { applyResult: args.applyResult } : {}),
created: args.created,
};
}
export function capabilitiesRequiredError(args: {
pluginId: string;
pluginVersion: string;
required: string[];
granted: string[];
missing: string[];
}): ResolveSnapshotError {
const remediation = [
`od plugin trust ${args.pluginId} --capabilities ${args.missing.join(',')}`,
`or pass --grant-caps ${args.missing.join(',')} to the apply / run command`,
];
return {
ok: false,
status: 409,
exitCode: 66,
body: {
error: {
code: 'capabilities-required',
message: `Plugin ${args.pluginId} requires capabilities not yet granted.`,
data: {
pluginId: args.pluginId,
pluginVersion: args.pluginVersion,
required: args.required,
granted: args.granted,
missing: args.missing,
remediation,
},
},
},
};
}
// Convenience pass-through so tests that already imported the helper
// don't need to reach into other files.
export type { PluginConnectorBinding };

View file

@ -0,0 +1,179 @@
// Phase 4 / spec §14.1 — `od plugin scaffold` starter folder generator.
//
// Pure, file-system-side helper that materialises the §17.2 "enriched
// plugin" shape on disk: SKILL.md (canonical anchor, with the od:
// frontmatter the skills protocol expects) + open-design.json (sidecar
// with the v1 schema reference). Authors can drop the result into a
// new git repo and start iterating immediately; `od plugin install ./<id>`
// will pick it up via the local-folder backend.
//
// Kept module-pure (no daemon globals): tests pass a temp directory as
// `targetDir`; the CLI passes `process.cwd()`.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
export interface ScaffoldInput {
// Target directory the scaffold tree is created under. The function
// creates `<targetDir>/<id>/...`.
targetDir: string;
id: string;
title?: string;
description?: string;
taskKind?: 'new-generation' | 'code-migration' | 'figma-migration' | 'tune-collab';
mode?: string;
scenario?: string;
// When true, also drop a Claude Code-compatible plugin.json so the
// resulting repo lands on every catalog in §3 of the spec without
// modification (clawhub / awesome-agent-skills / claude-plugins).
withClaudePlugin?: boolean;
}
export interface ScaffoldResult {
folder: string;
files: string[];
}
const SAFE_ID = /^[a-z][a-z0-9._-]{0,62}$/;
export class ScaffoldError extends Error {
constructor(message: string) {
super(message);
this.name = 'ScaffoldError';
}
}
export async function scaffoldPlugin(input: ScaffoldInput): Promise<ScaffoldResult> {
if (!SAFE_ID.test(input.id)) {
throw new ScaffoldError(`plugin id "${input.id}" must be lowercase, start with a letter, and use [a-z0-9._-]`);
}
const folder = path.join(input.targetDir, input.id);
// Refuse to clobber a directory that already has any of the canonical
// files we'd emit. The caller can rm -rf and re-run if they really
// mean it.
try {
const entries = await fsp.readdir(folder).catch(() => []);
const conflicts = entries.filter((e) =>
e === 'SKILL.md' || e === 'open-design.json' || e === '.claude-plugin' || e === 'README.md',
);
if (conflicts.length > 0) {
throw new ScaffoldError(`destination ${folder} already contains ${conflicts.join(', ')}; refusing to overwrite`);
}
} catch (err) {
if (err instanceof ScaffoldError) throw err;
// ENOENT is expected on first run; surface anything else.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
const title = input.title?.trim() || humanize(input.id);
const description = input.description?.trim() || `One-paragraph description of ${title}.`;
const taskKind = input.taskKind ?? 'new-generation';
await fsp.mkdir(folder, { recursive: true });
const written: string[] = [];
const skillFrontmatter = [
'---',
`name: ${input.id}`,
`description: ${description}`,
'od:',
` mode: ${input.mode ?? 'prototype'}`,
` scenario: ${input.scenario ?? 'general'}`,
'---',
'',
`# ${title}`,
'',
'Workflow steps:',
'',
'1. Discovery / clarifying questions.',
'2. Plan + direction picker.',
'3. Generate the artifact.',
'4. Self-critique against the design system + craft rules.',
'',
`Replace this body with the actual ${title} workflow before publishing.`,
'',
].join('\n');
const skillPath = path.join(folder, 'SKILL.md');
await fsp.writeFile(skillPath, skillFrontmatter, 'utf8');
written.push(skillPath);
const manifest: Record<string, unknown> = {
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
specVersion: '1.0.0',
name: input.id,
title,
version: '0.1.0',
description,
license: 'MIT',
tags: [taskKind],
compat: { agentSkills: [{ path: './SKILL.md' }] },
od: {
kind: 'skill',
taskKind,
mode: input.mode ?? 'prototype',
scenario: input.scenario ?? 'general',
useCase: { query: `Generate a ${title.toLowerCase()} for {{audience}}.` },
context: {
skills: [{ ref: input.id }],
atoms: ['discovery-question-form', 'todo-write'],
},
inputs: [
{ name: 'audience', type: 'string', required: true, label: 'Audience' },
],
capabilities: ['prompt:inject'],
},
};
const manifestPath = path.join(folder, 'open-design.json');
await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
written.push(manifestPath);
const readme = [
`# ${title}`,
'',
description,
'',
'## Try it',
'',
'```bash',
`od plugin install ./${input.id}`,
`od plugin apply ${input.id} --input audience=VC`,
'```',
'',
'## Files',
'',
'- `SKILL.md` — the canonical agent skill body.',
'- `open-design.json` — the versioned Open Design marketplace sidecar.',
'',
'Edit `SKILL.md` to teach the agent how to perform the workflow.',
'Edit `open-design.json` to refine the marketplace card and inputs.',
'',
].join('\n');
const readmePath = path.join(folder, 'README.md');
await fsp.writeFile(readmePath, readme, 'utf8');
written.push(readmePath);
if (input.withClaudePlugin) {
const claudeDir = path.join(folder, '.claude-plugin');
await fsp.mkdir(claudeDir, { recursive: true });
const cp = {
name: input.id,
description,
version: '0.1.0',
};
const cpPath = path.join(claudeDir, 'plugin.json');
await fsp.writeFile(cpPath, JSON.stringify(cp, null, 2) + '\n', 'utf8');
written.push(cpPath);
}
return { folder, files: written };
}
function humanize(id: string): string {
return id
.replace(/[-_]+/g, ' ')
.split(' ')
.filter(Boolean)
.map((part) => part[0]!.toUpperCase() + part.slice(1))
.join(' ');
}

View file

@ -0,0 +1,115 @@
// Phase 4 / spec §13 / plan §3.Y1 — installed-plugin search + filters.
//
// Pure helper that filters and ranks an in-memory list of
// InstalledPluginRecord by an optional free-text query, plus
// structural filters (taskKind / mode / tag / trust).
//
// The function is consumed by:
// - `od plugin search <query>` (new in §3.Y1) — searches installed
// plugins for discovery,
// - `od plugin list --filter ...` (new in §3.Y1) — same filter
// surface without requiring a query.
//
// The matcher is intentionally simple — case-insensitive substring
// across id, title, description, and tag list. Spec §13's full-text
// + relevance-ranked search lives on the marketplace catalog side;
// this helper is for the local installed roster only and never
// pretends to be a search engine.
import type { InstalledPluginRecord, PluginManifest, TrustTier } from '@open-design/contracts';
export interface SearchInstalledPluginsInput {
plugins: ReadonlyArray<InstalledPluginRecord>;
// Free-text matcher. Empty / undefined → no text filter.
query?: string;
// Filter shapes (all optional, all AND'd together).
taskKind?: string;
mode?: string;
tag?: string;
trust?: TrustTier;
// When true, restrict to bundled plugins (source_kind='bundled'
// OR trust='bundled'). False excludes them. Default: include all.
bundled?: boolean;
}
export interface SearchInstalledPluginsResultEntry {
plugin: InstalledPluginRecord;
// Lower is a better match. Roughly: 0=id exact, 1=title exact,
// 2=tag exact, 3=substring on id, 4=substring on title, 5=
// description, 6=tag substring, 9=structural-filter-only match.
// Pure relative-rank field; the absolute number isn't an API.
rank: number;
// Which fields fired the match. Useful for the CLI to surface
// 'matched on tag: figma-migration'.
matched: Array<'id' | 'title' | 'description' | 'tag' | 'taskKind' | 'mode' | 'trust' | 'bundled'>;
}
export interface SearchInstalledPluginsResult {
entries: SearchInstalledPluginsResultEntry[];
total: number;
}
export function searchInstalledPlugins(input: SearchInstalledPluginsInput): SearchInstalledPluginsResult {
const query = (input.query ?? '').trim().toLowerCase();
const taskKind = input.taskKind?.trim().toLowerCase();
const mode = input.mode?.trim().toLowerCase();
const tag = input.tag?.trim().toLowerCase();
const trust = input.trust;
const bundledFilter = input.bundled;
const out: SearchInstalledPluginsResultEntry[] = [];
for (const plugin of input.plugins) {
const manifest = plugin.manifest;
const matched: SearchInstalledPluginsResultEntry['matched'] = [];
// Structural filters first (cheap; short-circuit on miss).
if (taskKind && (manifest.od?.taskKind ?? '').toLowerCase() !== taskKind) continue;
if (taskKind) matched.push('taskKind');
if (mode && (manifest.od?.mode ?? '').toLowerCase() !== mode) continue;
if (mode) matched.push('mode');
if (trust && plugin.trust !== trust) continue;
if (trust) matched.push('trust');
if (typeof bundledFilter === 'boolean') {
const isBundled = plugin.sourceKind === 'bundled' || plugin.trust === 'bundled';
if (isBundled !== bundledFilter) continue;
if (bundledFilter) matched.push('bundled');
}
const tags = collectTags(manifest);
if (tag && !tags.some((t) => t.toLowerCase() === tag)) continue;
if (tag) matched.push('tag');
// Free-text rank.
let rank: number | undefined;
if (query) {
const id = plugin.id.toLowerCase();
const title = (plugin.title ?? manifest.title ?? '').toLowerCase();
const description = (manifest.description ?? '').toLowerCase();
if (id === query) { rank = 0; matched.push('id'); }
else if (title === query) { rank = 1; matched.push('title'); }
else if (tags.some((t) => t.toLowerCase() === query)) { rank = 2; matched.push('tag'); }
else if (id.includes(query)) { rank = 3; matched.push('id'); }
else if (title.includes(query)) { rank = 4; matched.push('title'); }
else if (description.includes(query)) { rank = 5; matched.push('description'); }
else if (tags.some((t) => t.toLowerCase().includes(query))) { rank = 6; matched.push('tag'); }
else continue; // query supplied but no field matched → skip.
} else {
rank = matched.length > 0 ? 9 : 0; // no query: all surviving entries kept
}
out.push({ plugin, rank, matched });
}
// Stable sort: rank ascending, then id alphabetical for tie-break.
out.sort((a, b) => {
if (a.rank !== b.rank) return a.rank - b.rank;
return a.plugin.id.localeCompare(b.plugin.id);
});
return { entries: out, total: out.length };
}
function collectTags(manifest: PluginManifest): string[] {
const raw = manifest.tags;
if (!Array.isArray(raw)) return [];
return raw.filter((t): t is string => typeof t === 'string' && t.length > 0);
}

View file

@ -0,0 +1,208 @@
// Phase 4 / spec §10.1 / plan §3.EE1 — pipeline simulator.
//
// Pure helper that walks a `PluginPipeline` against a caller-
// supplied signal stream and reports per-stage convergence
// without running an actual LLM / agent. Useful for plugin
// authors testing devloop convergence rules:
//
// - 'does my critique-theater stage exit on score>=4?'
// - 'does my build-test stage timeout after 8 iterations?'
// - 'does my partial-decision diff-review re-prompt forever?'
//
// The simulator is deterministic: callers supply per-iteration
// signals as either a constant SignalSnapshot or a generator
// function `(iteration) => signals`. The pipeline's `until`
// expression is evaluated via the same evaluator the runtime
// uses (no parallel implementation drift).
//
// Sister of plugins/pipeline-runner.ts (which drives a live
// run); this module is the dry-run counterpart with no SQLite
// or SSE side effects.
import type { PluginPipeline } from '@open-design/contracts';
import { evaluateUntil, parseUntil, type UntilSignals } from './until.js';
export interface StageSignalProvider {
/**
* Returns the signals snapshot for a given (stageId, iteration)
* tuple. The caller defines the simulation policy; common
* choices:
*
* - constant signals across every stage (`() => ({ 'critique.score': 5 })`)
* - per-stage tables (`(stageId) => stageSignals[stageId] ?? {}`)
* - increasing signals across iterations (e.g. score grows
* toward convergence)
*/
(stageId: string, iteration: number): UntilSignals;
}
export interface SimulatePipelineInput {
pipeline: PluginPipeline;
signals: StageSignalProvider | UntilSignals;
// Per-stage iteration cap. Defaults to 10. The simulator clamps
// 'repeat: true' stages at this ceiling even when the until
// expression never satisfies — surfaces a 'never converges'
// bug as a hit-cap event rather than an infinite loop.
iterationCap?: number;
}
export interface SimulateStageOutcome {
stageId: string;
iterations: number;
// 'converged' = until satisfied. 'cap' = iterationCap hit before
// until satisfied (for repeat:true stages). 'unparsable' = until
// expression failed to parse (the simulator records this as a
// single-iteration unparsable run rather than re-throwing).
// 'single' = repeat:false stage; ran exactly once.
outcome: 'converged' | 'cap' | 'unparsable' | 'single';
// The signals snapshot from the iteration that triggered the
// exit, for audit. For 'single' / 'cap', the last iteration's
// snapshot.
finalSignals: UntilSignals;
// The matched conjunction (when outcome='converged'). Empty
// otherwise. Useful when a stage has multiple OR branches and
// the author wants to know which one fired.
matched?: ReturnType<typeof evaluateUntil>['matched'];
// Reason text for 'unparsable' / 'cap' outcomes. Absent on
// converged / single.
reason?: string;
}
export interface SimulatePipelineResult {
stages: SimulateStageOutcome[];
// Aggregate: total iterations across every stage.
totalIterations: number;
// 'all-converged' / 'all-single' / 'mixed' / 'cap-hit' /
// 'unparsable'. Quick-look outcome the CLI prints first.
outcome: 'all-converged' | 'all-single' | 'mixed' | 'cap-hit' | 'unparsable';
}
const DEFAULT_ITERATION_CAP = 10;
export function simulatePipeline(input: SimulatePipelineInput): SimulatePipelineResult {
const cap = input.iterationCap ?? DEFAULT_ITERATION_CAP;
const provider: StageSignalProvider = typeof input.signals === 'function'
? input.signals as StageSignalProvider
: () => input.signals as UntilSignals;
const stages: SimulateStageOutcome[] = [];
for (const stage of input.pipeline.stages) {
const stageOutcome = simulateStage(stage, provider, cap);
stages.push(stageOutcome);
}
const totalIterations = stages.reduce((acc, s) => acc + s.iterations, 0);
const outcome = aggregateOutcome(stages);
return { stages, totalIterations, outcome };
}
function simulateStage(
stage: PluginPipeline['stages'][number],
provider: StageSignalProvider,
cap: number,
): SimulateStageOutcome {
const stageId = stage.id;
const repeat = stage.repeat === true;
const untilSource = stage.until;
if (!repeat || !untilSource) {
// Single-shot stage: one iteration, no until eval.
const finalSignals = provider(stageId, 0);
const out: SimulateStageOutcome = {
stageId,
iterations: 1,
outcome: 'single',
finalSignals,
};
return out;
}
let parsed;
try {
parsed = parseUntil(untilSource);
} catch (err) {
const finalSignals = provider(stageId, 0);
return {
stageId,
iterations: 1,
outcome: 'unparsable',
finalSignals,
reason: (err as Error).message,
};
}
let lastSignals: UntilSignals = {};
for (let i = 0; i < cap; i++) {
const signals = provider(stageId, i);
lastSignals = signals;
const eval_ = evaluateUntil(parsed, signals);
if (eval_.satisfied) {
const out: SimulateStageOutcome = {
stageId,
iterations: i + 1,
outcome: 'converged',
finalSignals: signals,
};
if (eval_.matched.length > 0) out.matched = eval_.matched;
return out;
}
}
return {
stageId,
iterations: cap,
outcome: 'cap',
finalSignals: lastSignals,
reason: `until expression never satisfied within iterationCap=${cap}`,
};
}
function aggregateOutcome(stages: SimulateStageOutcome[]): SimulatePipelineResult['outcome'] {
if (stages.length === 0) return 'all-single';
if (stages.some((s) => s.outcome === 'unparsable')) return 'unparsable';
if (stages.some((s) => s.outcome === 'cap')) return 'cap-hit';
if (stages.every((s) => s.outcome === 'single')) return 'all-single';
if (stages.every((s) => s.outcome === 'converged' || s.outcome === 'single')) {
return stages.every((s) => s.outcome === 'converged') ? 'all-converged' : 'mixed';
}
return 'mixed';
}
// Convenience helper: parse a key=value list of signals from CLI
// flags. 'critique.score=4 build.passing=true tests.passing=false'
// → { 'critique.score': 4, 'build.passing': true, 'tests.passing': false }
//
// Respects the closed UntilSignals vocabulary: unknown keys are
// dropped with a warning so a typo doesn't silently make the
// simulator pass.
export function parseSignalKv(args: ReadonlyArray<string>): { signals: UntilSignals; warnings: string[] } {
const out: UntilSignals = {};
const warnings: string[] = [];
const knownNumeric: Array<keyof UntilSignals> = ['critique.score', 'iterations'];
const knownBoolean: Array<keyof UntilSignals> = ['user.confirmed', 'preview.ok', 'build.passing', 'tests.passing'];
for (const arg of args) {
const eq = arg.indexOf('=');
if (eq <= 0) {
warnings.push(`signal must be 'key=value', got '${arg}'`);
continue;
}
const key = arg.slice(0, eq).trim();
const raw = arg.slice(eq + 1).trim();
if (knownNumeric.includes(key as keyof UntilSignals)) {
const num = Number(raw);
if (Number.isFinite(num)) {
(out as Record<string, number>)[key] = num;
} else {
warnings.push(`signal ${key} expected number; got '${raw}'`);
}
continue;
}
if (knownBoolean.includes(key as keyof UntilSignals)) {
if (raw === 'true' || raw === '1') (out as Record<string, boolean>)[key] = true;
else if (raw === 'false' || raw === '0') (out as Record<string, boolean>)[key] = false;
else warnings.push(`signal ${key} expected boolean; got '${raw}'`);
continue;
}
warnings.push(`unknown signal '${key}' (allowed: critique.score / iterations / user.confirmed / preview.ok / build.passing / tests.passing)`);
}
return { signals: out, warnings };
}

View file

@ -0,0 +1,212 @@
// Phase 4 / spec §11.5 / plan §3.BB2 — snapshot diff helper.
//
// Pure helper that compares two AppliedPluginSnapshot values and
// returns a structured report. Useful for:
//
// - debugging replay invariance ('e2e-2: applying the same plugin
// twice produces byte-equal digests'),
// - inspecting why a re-apply produced a different snapshot
// ('did the resolved skills change between turns?'),
// - comparing the snapshot a run launched against vs. the live
// plugin's current apply.
//
// Sister of plugins/diff.ts (which compares InstalledPluginRecord
// values). The shape is intentionally identical so renderers reuse
// the same +/-/~ glyph format.
import type { AppliedPluginSnapshot } from '@open-design/contracts';
export interface SnapshotDiffEntry {
field: string;
kind: 'added' | 'removed' | 'changed';
before?: string;
after?: string;
summary?: string;
}
export interface SnapshotDiffReport {
// Same id when both sides share an id; otherwise inferred missing.
pluginId?: string;
// Were the two snapshots' manifestSourceDigests equal? Surfaces
// the e2e-2 invariance check at-a-glance.
digestEqual: boolean;
entries: SnapshotDiffEntry[];
added: number;
removed: number;
changed: number;
}
export interface DiffSnapshotsInput {
a: AppliedPluginSnapshot;
b: AppliedPluginSnapshot;
}
export function diffSnapshots(input: DiffSnapshotsInput): SnapshotDiffReport {
const { a, b } = input;
const entries: SnapshotDiffEntry[] = [];
// Identity + lineage.
diffScalar(entries, 'snapshotId', a.snapshotId, b.snapshotId);
diffScalar(entries, 'pluginId', a.pluginId, b.pluginId);
diffScalar(entries, 'pluginSpecVersion', a.pluginSpecVersion, b.pluginSpecVersion);
diffScalar(entries, 'pluginVersion', a.pluginVersion, b.pluginVersion);
diffScalar(entries, 'manifestSourceDigest', a.manifestSourceDigest, b.manifestSourceDigest);
diffScalar(entries, 'sourceMarketplaceId', a.sourceMarketplaceId, b.sourceMarketplaceId);
diffScalar(entries, 'pinnedRef', a.pinnedRef, b.pinnedRef);
diffScalar(entries, 'taskKind', a.taskKind, b.taskKind);
diffScalar(entries, 'status', a.status, b.status);
diffScalar(entries, 'pluginTitle', a.pluginTitle, b.pluginTitle);
diffScalar(entries, 'pluginDescription', a.pluginDescription, b.pluginDescription);
diffScalar(entries, 'query', a.query, b.query);
// Inputs (typed scalar map).
diffMap(entries, 'inputs', recordToStringMap(a.inputs), recordToStringMap(b.inputs));
// Capabilities.
diffArray(entries, 'capabilitiesRequired', a.capabilitiesRequired, b.capabilitiesRequired);
diffArray(entries, 'capabilitiesGranted', a.capabilitiesGranted, b.capabilitiesGranted);
// Resolved context items (compare by ref, the digest input).
diffArray(entries, 'resolvedContext.items',
contextRefs(a.resolvedContext?.items),
contextRefs(b.resolvedContext?.items),
);
// Connectors / MCP.
diffArray(entries, 'connectorsRequired',
nonEmptyStrings((a.connectorsRequired ?? []).map((c) => safeString((c as { id?: unknown })?.id))),
nonEmptyStrings((b.connectorsRequired ?? []).map((c) => safeString((c as { id?: unknown })?.id))));
diffArray(entries, 'connectorsResolved',
nonEmptyStrings((a.connectorsResolved ?? []).map((c) => `${safeString((c as { id?: unknown })?.id)}:${safeString((c as { status?: unknown })?.status)}`)),
nonEmptyStrings((b.connectorsResolved ?? []).map((c) => `${safeString((c as { id?: unknown })?.id)}:${safeString((c as { status?: unknown })?.status)}`)));
diffArray(entries, 'mcpServers',
nonEmptyStrings((a.mcpServers ?? []).map((m) => safeString((m as { id?: unknown })?.id))),
nonEmptyStrings((b.mcpServers ?? []).map((m) => safeString((m as { id?: unknown })?.id))));
// GenUI surfaces (by id).
diffArray(entries, 'genuiSurfaces',
nonEmptyStrings((a.genuiSurfaces ?? []).map((s) => safeString((s as { id?: unknown })?.id))),
nonEmptyStrings((b.genuiSurfaces ?? []).map((s) => safeString((s as { id?: unknown })?.id))));
// Pipeline (by stage id roster + per-stage atoms).
diffPipeline(entries, a.pipeline, b.pipeline);
// Assets (by path).
diffArray(entries, 'assetsStaged',
nonEmptyStrings((a.assetsStaged ?? []).map((x) => safeString((x as { path?: unknown })?.path))),
nonEmptyStrings((b.assetsStaged ?? []).map((x) => safeString((x as { path?: unknown })?.path))));
entries.sort((x, y) => x.field.localeCompare(y.field));
let added = 0; let removed = 0; let changed = 0;
for (const e of entries) {
if (e.kind === 'added') added++;
if (e.kind === 'removed') removed++;
if (e.kind === 'changed') changed++;
}
const report: SnapshotDiffReport = {
digestEqual: a.manifestSourceDigest === b.manifestSourceDigest,
entries,
added, removed, changed,
};
if (a.pluginId === b.pluginId) report.pluginId = a.pluginId;
return report;
}
function nonEmptyStrings(values: ReadonlyArray<string>): string[] {
return values.filter((s): s is string => typeof s === 'string' && s.length > 0);
}
function safeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function recordToStringMap(input: Record<string, string | number | boolean> | undefined | null): Record<string, string> {
const out: Record<string, string> = {};
if (!input) return out;
for (const [k, v] of Object.entries(input)) out[k] = String(v);
return out;
}
function contextRefs(items: AppliedPluginSnapshot['resolvedContext']['items'] | undefined): string[] {
if (!Array.isArray(items)) return [];
return items.map((i) => {
if (!i) return '';
const anyItem = i as { kind?: string; id?: string; label?: string; path?: string };
const ref = anyItem.id ?? anyItem.path ?? anyItem.label ?? '';
return `${anyItem.kind ?? ''}:${ref}`;
}).filter((s) => s.length > 1);
}
function diffPipeline(
out: SnapshotDiffEntry[],
a: AppliedPluginSnapshot['pipeline'] | undefined,
b: AppliedPluginSnapshot['pipeline'] | undefined,
): void {
if (!a && !b) return;
if (!a && b) { out.push({ field: 'pipeline', kind: 'added', after: b.stages?.map((s) => s.id).join(' \u2192 ') ?? '' }); return; }
if (a && !b) { out.push({ field: 'pipeline', kind: 'removed', before: a.stages?.map((s) => s.id).join(' \u2192 ') ?? '' }); return; }
diffArray(out, 'pipeline.stages', (a!.stages ?? []).map((s) => s.id), (b!.stages ?? []).map((s) => s.id));
const aBy = new Map((a!.stages ?? []).map((s) => [s.id, s] as const));
const bBy = new Map((b!.stages ?? []).map((s) => [s.id, s] as const));
for (const id of new Set([...aBy.keys(), ...bBy.keys()])) {
const sa = aBy.get(id);
const sb = bBy.get(id);
if (!sa || !sb) continue;
diffArray(out, `pipeline.stages[${id}].atoms`, sa.atoms ?? [], sb.atoms ?? []);
diffScalar(out, `pipeline.stages[${id}].until`, sa.until, sb.until);
}
}
function diffScalar(out: SnapshotDiffEntry[], field: string, a: unknown, b: unknown): void {
const aPresent = a !== undefined && a !== null;
const bPresent = b !== undefined && b !== null;
if (!aPresent && !bPresent) return;
if (!aPresent) { out.push({ field, kind: 'added', after: String(b) }); return; }
if (!bPresent) { out.push({ field, kind: 'removed', before: String(a) }); return; }
if (toComparable(a) === toComparable(b)) return;
out.push({ field, kind: 'changed', before: String(a), after: String(b) });
}
function diffArray(out: SnapshotDiffEntry[], field: string, a: ReadonlyArray<string>, b: ReadonlyArray<string>): void {
const setA = new Set(a);
const setB = new Set(b);
const added = [...setB].filter((x) => !setA.has(x));
const removed = [...setA].filter((x) => !setB.has(x));
if (added.length === 0 && removed.length === 0) {
if (a.length === b.length && a.every((v, i) => v === b[i])) return;
out.push({ field, kind: 'changed', before: a.join(','), after: b.join(','), summary: `reordered (${a.length} entries)` });
return;
}
out.push({ field, kind: 'changed', summary: `${added.length} added, ${removed.length} removed`, before: removed.join(','), after: added.join(',') });
}
function diffMap(out: SnapshotDiffEntry[], field: string, a: Record<string, string>, b: Record<string, string>): void {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
const added: string[] = [];
const removed: string[] = [];
const changed: string[] = [];
for (const k of keys) {
const av = a[k];
const bv = b[k];
if (av === undefined && bv !== undefined) { added.push(`${k}=${bv}`); continue; }
if (av !== undefined && bv === undefined) { removed.push(`${k}=${av}`); continue; }
if (av !== bv) changed.push(`${k}: ${av} \u2192 ${bv}`);
}
if (added.length === 0 && removed.length === 0 && changed.length === 0) return;
out.push({
field,
kind: 'changed',
summary: `${added.length} added, ${removed.length} removed, ${changed.length} changed`,
before: removed.join(', '),
after: [...added, ...changed].join(', '),
});
}
function toComparable(value: unknown): string {
if (value === undefined || value === null) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number') return String(value);
if (typeof value === 'boolean') return value ? '1' : '0';
try { return JSON.stringify(value); } catch { return String(value); }
}

View file

@ -0,0 +1,383 @@
// Snapshot writer. Spec §8.2.1 demands this be the only module that issues
// INSERT/UPDATE against `applied_plugin_snapshots`. Plan §2 names a CI guard
// for the rule; the apply pipeline must never touch the table directly.
//
// Phase 1 ships:
// - createSnapshot() — INSERT a fresh row, stamping expires_at via the
// PB2 unreferenced TTL knob.
// - getSnapshot() — read by id.
// - linkSnapshotToRun() — once a run starts off the snapshot, pin
// expires_at = NULL and update run_id (the snapshot is now referenced).
// - markSnapshotStale() — `od plugin doctor` flips status='stale' after a
// plugin upgrade. We never rewrite the resolved_context_json, so historic
// reproducibility wins over freshness.
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { readPluginEnvKnobs } from '../app-config.js';
import {
OPEN_DESIGN_PLUGIN_SPEC_VERSION,
type AppliedPluginSnapshot,
type GenUISurfaceSpec,
type McpServerSpec,
type PluginAssetRef,
type PluginConnectorBinding,
type PluginConnectorRef,
type PluginPipeline,
type ResolvedContext,
} from '@open-design/contracts';
type SqliteDb = Database.Database;
type DbRow = Record<string, unknown>;
export interface CreateSnapshotInput {
projectId: string;
conversationId?: string | null | undefined;
runId?: string | null | undefined;
pluginId: string;
pluginSpecVersion?: string | null | undefined;
pluginVersion: string;
pluginTitle?: string | undefined;
pluginDescription?: string | undefined;
manifestSourceDigest: string;
sourceMarketplaceId?: string | null | undefined;
sourceMarketplaceEntryName?: string | null | undefined;
sourceMarketplaceEntryVersion?: string | null | undefined;
marketplaceTrust?: 'official' | 'trusted' | 'restricted' | null | undefined;
resolvedSource?: string | null | undefined;
resolvedRef?: string | null | undefined;
archiveIntegrity?: string | null | undefined;
pinnedRef?: string | null | undefined;
taskKind: AppliedPluginSnapshot['taskKind'];
inputs: Record<string, string | number | boolean>;
resolvedContext: ResolvedContext;
pipeline?: PluginPipeline | undefined;
genuiSurfaces?: GenUISurfaceSpec[] | undefined;
capabilitiesGranted: string[];
capabilitiesRequired: string[];
assetsStaged: PluginAssetRef[];
connectorsRequired: PluginConnectorRef[];
connectorsResolved: PluginConnectorBinding[];
mcpServers: McpServerSpec[];
query?: string | undefined;
}
export function createSnapshot(db: SqliteDb, input: CreateSnapshotInput): AppliedPluginSnapshot {
const id = randomUUID();
const now = Date.now();
const knobs = readPluginEnvKnobs();
// Per PB2: when a snapshot is created without an associated run, stamp an
// expiry; when a run is already linked, the snapshot is referenced and the
// GC worker never touches it.
const expiresAt = input.runId
? null
: knobs.snapshotUnreferencedTtlDays > 0
? now + knobs.snapshotUnreferencedTtlDays * 24 * 60 * 60 * 1000
: null;
db.prepare(`
INSERT INTO applied_plugin_snapshots (
id, project_id, conversation_id, run_id, plugin_id, plugin_spec_version, plugin_version,
manifest_source_digest, source_marketplace_id, source_marketplace_entry_name,
source_marketplace_entry_version, marketplace_trust, resolved_source,
resolved_ref, archive_integrity, pinned_ref, task_kind,
inputs_json, resolved_context_json, pipeline_json, genui_surfaces_json,
capabilities_granted, capabilities_required, assets_staged_json,
connectors_required_json, connectors_resolved_json, mcp_servers_json,
plugin_title, plugin_description, query_text,
status, applied_at, expires_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'fresh', ?, ?)
`).run(
id,
input.projectId,
input.conversationId ?? null,
input.runId ?? null,
input.pluginId,
input.pluginSpecVersion ?? OPEN_DESIGN_PLUGIN_SPEC_VERSION,
input.pluginVersion,
input.manifestSourceDigest,
input.sourceMarketplaceId ?? null,
input.sourceMarketplaceEntryName ?? null,
input.sourceMarketplaceEntryVersion ?? null,
input.marketplaceTrust ?? null,
input.resolvedSource ?? null,
input.resolvedRef ?? null,
input.archiveIntegrity ?? null,
input.pinnedRef ?? null,
input.taskKind,
JSON.stringify(input.inputs),
JSON.stringify(input.resolvedContext),
input.pipeline ? JSON.stringify(input.pipeline) : null,
JSON.stringify(input.genuiSurfaces ?? []),
JSON.stringify(input.capabilitiesGranted),
JSON.stringify(input.capabilitiesRequired),
JSON.stringify(input.assetsStaged),
JSON.stringify(input.connectorsRequired),
JSON.stringify(input.connectorsResolved),
JSON.stringify(input.mcpServers),
input.pluginTitle ?? null,
input.pluginDescription ?? null,
input.query ?? null,
now,
expiresAt,
);
const snapshot: AppliedPluginSnapshot = buildSnapshot({
id,
appliedAt: now,
input,
status: 'fresh',
});
return snapshot;
}
export function getSnapshot(db: SqliteDb, snapshotId: string): AppliedPluginSnapshot | null {
const row = db.prepare(`SELECT * FROM applied_plugin_snapshots WHERE id = ?`).get(snapshotId) as DbRow | undefined;
if (!row) return null;
return rowToSnapshot(row);
}
export function listSnapshotsForProject(db: SqliteDb, projectId: string): AppliedPluginSnapshot[] {
const rows = db
.prepare(`SELECT * FROM applied_plugin_snapshots WHERE project_id = ? ORDER BY applied_at DESC`)
.all(projectId) as DbRow[];
return rows.map(rowToSnapshot);
}
export function linkSnapshotToRun(db: SqliteDb, snapshotId: string, runId: string): void {
db.prepare(`
UPDATE applied_plugin_snapshots
SET run_id = ?, expires_at = NULL
WHERE id = ?
`).run(runId, snapshotId);
}
// Pin a snapshot to a project row. Mirrors `linkSnapshotToRun` but
// updates `projects.applied_plugin_snapshot_id` (added by `migratePlugins`)
// and also clears `expires_at` because a project-pinned snapshot is now
// referenced (PB2 reproducibility-first). Idempotent — re-linking the
// same id is a no-op.
export function linkSnapshotToProject(db: SqliteDb, snapshotId: string, projectId: string): void {
db.prepare(
`UPDATE applied_plugin_snapshots
SET project_id = ?, expires_at = NULL
WHERE id = ?`,
).run(projectId, snapshotId);
db.prepare(
`UPDATE projects
SET applied_plugin_snapshot_id = ?
WHERE id = ?`,
).run(snapshotId, projectId);
}
// Pin a snapshot to a conversation row. Same shape as
// `linkSnapshotToProject` but mutates `conversations.applied_plugin_snapshot_id`.
// Used when a plugin is applied inside an existing chat composer (§8.4).
export function linkSnapshotToConversation(
db: SqliteDb,
snapshotId: string,
conversationId: string,
): void {
db.prepare(
`UPDATE applied_plugin_snapshots
SET conversation_id = ?, expires_at = NULL
WHERE id = ?`,
).run(conversationId, snapshotId);
db.prepare(
`UPDATE conversations
SET applied_plugin_snapshot_id = ?
WHERE id = ?`,
).run(snapshotId, conversationId);
}
export function markSnapshotStale(db: SqliteDb, snapshotId: string): void {
db.prepare(`UPDATE applied_plugin_snapshots SET status = 'stale' WHERE id = ?`).run(snapshotId);
}
// Phase 5 / PB2 enforcement (§16). Deletes every `applied_plugin_snapshots`
// row whose `expires_at` is non-null and not in the future. Returns the
// count + the ids that were removed so callers can audit. Pure side-effect
// over SQLite; safe to call from a periodic worker. The caller decides the
// `now` clock so tests can pin time.
export interface PruneExpiredResult {
removed: number;
ids: string[];
}
export interface PruneExpiredOptions {
// Override for tests so the clock is deterministic.
now?: number;
// Operator escape hatch: force-delete unreferenced rows older than
// this unix-ms timestamp even when their TTL has not yet expired.
// Does NOT touch referenced rows (run_id IS NOT NULL); the
// `retentionDays` knob below is the only way to reach those.
before?: number;
// Plan §3.M1 / spec PB2 / §16 Phase 5 — operator-opt-in
// referenced-row TTL. When set, snapshots are eligible for deletion
// even after they have been linked to a run / conversation / project,
// provided two conditions:
//
// (1) `applied_at < now - retentionDays * 86_400_000`
// (2) the referenced run / conversation / project is "terminal"
//
// v1 implements (2) as: the snapshot's `project_id` no longer
// appears in `projects` (i.e. the project was deleted). Runs are
// in-memory in v1 so we cannot distinguish "active" vs "completed"
// from SQLite alone; `conversations.archived_at` does not exist.
// The conservative rule keeps reproducibility wins for live
// projects while letting operators clean up after `od project
// delete <id>` so dangling snapshot rows don't accumulate.
retentionDays?: number;
}
export function pruneExpiredSnapshots(
db: SqliteDb,
options: PruneExpiredOptions = {},
): PruneExpiredResult {
const now = options.now ?? Date.now();
const cutoff = typeof options.before === 'number' ? options.before : now;
const expiredIds = db
.prepare(
`SELECT id FROM applied_plugin_snapshots
WHERE expires_at IS NOT NULL AND expires_at <= ?`,
)
.all(cutoff) as Array<{ id: string }>;
const beforeIds = typeof options.before === 'number'
? (db
.prepare(
`SELECT id FROM applied_plugin_snapshots
WHERE expires_at IS NULL AND run_id IS NULL AND applied_at <= ?`,
)
.all(options.before) as Array<{ id: string }>)
: [];
// Plan §3.M1 — referenced-row TTL.
//
// Pull every snapshot whose `applied_at` is older than
// `now - retentionDays * 86_400_000` and whose `project_id` no
// longer exists in `projects`. The LEFT JOIN approach lets us run
// a single query per sweep instead of N project lookups.
const retentionIds: Array<{ id: string }> = [];
if (typeof options.retentionDays === 'number' && options.retentionDays > 0) {
const retentionCutoff = now - options.retentionDays * 24 * 60 * 60 * 1000;
const rows = db
.prepare(
`SELECT s.id AS id
FROM applied_plugin_snapshots s
LEFT JOIN projects p ON p.id = s.project_id
WHERE s.applied_at <= ?
AND p.id IS NULL`,
)
.all(retentionCutoff) as Array<{ id: string }>;
retentionIds.push(...rows);
}
const ids = [...expiredIds, ...beforeIds, ...retentionIds].map((r) => r.id);
// Dedupe — a row might match both expires_at and retentionDays.
const unique = Array.from(new Set(ids));
if (unique.length === 0) return { removed: 0, ids: [] };
const placeholders = unique.map(() => '?').join(', ');
db.prepare(`DELETE FROM applied_plugin_snapshots WHERE id IN (${placeholders})`).run(...unique);
return { removed: unique.length, ids: unique };
}
export function countSnapshotsForProject(db: SqliteDb, projectId: string): number {
const row = db.prepare(`SELECT COUNT(*) AS n FROM applied_plugin_snapshots WHERE project_id = ?`).get(projectId) as DbRow;
return Number(row['n'] ?? 0);
}
function buildSnapshot(args: {
id: string;
appliedAt: number;
input: CreateSnapshotInput;
status: AppliedPluginSnapshot['status'];
}): AppliedPluginSnapshot {
const { id, appliedAt, input, status } = args;
const snapshot: AppliedPluginSnapshot = {
snapshotId: id,
pluginId: input.pluginId,
pluginSpecVersion: input.pluginSpecVersion ?? OPEN_DESIGN_PLUGIN_SPEC_VERSION,
pluginVersion: input.pluginVersion,
manifestSourceDigest: input.manifestSourceDigest,
sourceMarketplaceId: input.sourceMarketplaceId ?? undefined,
sourceMarketplaceEntryName: input.sourceMarketplaceEntryName ?? undefined,
sourceMarketplaceEntryVersion: input.sourceMarketplaceEntryVersion ?? undefined,
marketplaceTrust: input.marketplaceTrust ?? undefined,
resolvedSource: input.resolvedSource ?? undefined,
resolvedRef: input.resolvedRef ?? undefined,
archiveIntegrity: input.archiveIntegrity ?? undefined,
pinnedRef: input.pinnedRef ?? undefined,
inputs: input.inputs,
resolvedContext: input.resolvedContext,
capabilitiesGranted: input.capabilitiesGranted,
capabilitiesRequired: input.capabilitiesRequired,
assetsStaged: input.assetsStaged,
taskKind: input.taskKind,
appliedAt,
connectorsRequired: input.connectorsRequired,
connectorsResolved: input.connectorsResolved,
mcpServers: input.mcpServers,
pipeline: input.pipeline,
genuiSurfaces: input.genuiSurfaces,
pluginTitle: input.pluginTitle,
pluginDescription: input.pluginDescription,
query: input.query,
status,
};
return snapshot;
}
export function rowToSnapshot(row: DbRow): AppliedPluginSnapshot {
const pipeline = parseJsonOrUndefined<PluginPipeline>(row['pipeline_json']);
const snapshot: AppliedPluginSnapshot = {
snapshotId: String(row['id']),
pluginId: String(row['plugin_id']),
pluginSpecVersion: row['plugin_spec_version'] != null ? String(row['plugin_spec_version']) : undefined,
pluginVersion: String(row['plugin_version']),
manifestSourceDigest: String(row['manifest_source_digest']),
sourceMarketplaceId: row['source_marketplace_id'] != null ? String(row['source_marketplace_id']) : undefined,
sourceMarketplaceEntryName: row['source_marketplace_entry_name'] != null ? String(row['source_marketplace_entry_name']) : undefined,
sourceMarketplaceEntryVersion: row['source_marketplace_entry_version'] != null ? String(row['source_marketplace_entry_version']) : undefined,
marketplaceTrust: row['marketplace_trust'] != null ? row['marketplace_trust'] as AppliedPluginSnapshot['marketplaceTrust'] : undefined,
resolvedSource: row['resolved_source'] != null ? String(row['resolved_source']) : undefined,
resolvedRef: row['resolved_ref'] != null ? String(row['resolved_ref']) : undefined,
archiveIntegrity: row['archive_integrity'] != null ? String(row['archive_integrity']) : undefined,
pinnedRef: row['pinned_ref'] != null ? String(row['pinned_ref']) : undefined,
inputs: parseJsonOr<Record<string, string | number | boolean>>(row['inputs_json'], {}),
resolvedContext: parseJsonOr<ResolvedContext>(row['resolved_context_json'], { items: [] }),
capabilitiesGranted: parseJsonOr<string[]>(row['capabilities_granted'], []),
capabilitiesRequired: parseJsonOr<string[]>(row['capabilities_required'], []),
assetsStaged: parseJsonOr<PluginAssetRef[]>(row['assets_staged_json'], []),
taskKind: row['task_kind'] as AppliedPluginSnapshot['taskKind'],
appliedAt: Number(row['applied_at']),
connectorsRequired: parseJsonOr<PluginConnectorRef[]>(row['connectors_required_json'], []),
connectorsResolved: parseJsonOr<PluginConnectorBinding[]>(row['connectors_resolved_json'], []),
mcpServers: parseJsonOr<McpServerSpec[]>(row['mcp_servers_json'], []),
pipeline,
genuiSurfaces: parseJsonOr<GenUISurfaceSpec[]>(row['genui_surfaces_json'], []),
pluginTitle: row['plugin_title'] != null ? String(row['plugin_title']) : undefined,
pluginDescription: row['plugin_description'] != null ? String(row['plugin_description']) : undefined,
query: row['query_text'] != null ? String(row['query_text']) : undefined,
status: row['status'] === 'stale' ? 'stale' : 'fresh',
};
return snapshot;
}
function parseJsonOr<T>(value: unknown, fallback: T): T {
if (typeof value !== 'string' || value.length === 0) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
function parseJsonOrUndefined<T>(value: unknown): T | undefined {
if (typeof value !== 'string' || value.length === 0) return undefined;
try {
return JSON.parse(value) as T;
} catch {
return undefined;
}
}

View file

@ -0,0 +1,179 @@
// Phase 4 / spec §11.5 / plan §3.DD1 — installed-plugin inventory stats.
//
// Pure helper that aggregates an in-memory list of
// InstalledPluginRecord into a single health/inventory report.
// Used by:
//
// - `od plugin stats` — operator at-a-glance inventory check,
// - the desktop / web 'Plugins' settings panel (via the JSON
// mode of the same CLI),
// - the `od doctor` summary (a future patch can fold this in
// without reimplementing the aggregation).
//
// The function is intentionally pure; the daemon route wires the
// snapshot-side aggregation in separately because that side
// requires a SQLite read.
import type { InstalledPluginRecord, PluginSourceKind } from '@open-design/contracts';
export interface PluginInventoryStats {
total: number;
bySourceKind: Record<string, number>;
byTrust: Record<string, number>;
byTaskKind: Record<string, number>;
// Plugins that declared at least one of the §5.3 elevated
// capabilities (fs:write, subprocess, bash, network, connector*).
// Surfaces 'how many plugins can mutate state' for an audit panel.
withElevatedCapabilities: number;
// The bundled-vs-third-party split. Bundled plugins are re-
// registered every boot; third-party come from local / github /
// https / marketplace and never auto-upgrade.
bundled: number;
thirdParty: number;
// The newest install timestamp (epoch ms) so an operator can
// see 'when did anything change last?'.
lastInstalledAt: number | null;
// The newest update timestamp — distinct because re-running the
// installer on the same source bumps updatedAt without
// changing installedAt.
lastUpdatedAt: number | null;
}
const ELEVATED_CAPABILITIES = new Set(['fs:write', 'subprocess', 'bash', 'network']);
export function pluginInventoryStats(plugins: ReadonlyArray<InstalledPluginRecord>): PluginInventoryStats {
const stats: PluginInventoryStats = {
total: plugins.length,
bySourceKind: {},
byTrust: {},
byTaskKind: {},
withElevatedCapabilities: 0,
bundled: 0,
thirdParty: 0,
lastInstalledAt: null,
lastUpdatedAt: null,
};
for (const plugin of plugins) {
const kind = plugin.sourceKind ?? 'unknown';
stats.bySourceKind[kind] = (stats.bySourceKind[kind] ?? 0) + 1;
const trust = plugin.trust ?? 'unknown';
stats.byTrust[trust] = (stats.byTrust[trust] ?? 0) + 1;
const taskKind = plugin.manifest.od?.taskKind ?? 'unknown';
stats.byTaskKind[taskKind] = (stats.byTaskKind[taskKind] ?? 0) + 1;
const declared = plugin.manifest.od?.capabilities ?? [];
if (Array.isArray(declared) && declared.some((c) => ELEVATED_CAPABILITIES.has(c) || (typeof c === 'string' && c.startsWith('connector:')))) {
stats.withElevatedCapabilities++;
}
if (plugin.sourceKind === 'bundled' || plugin.trust === 'bundled') stats.bundled++;
else stats.thirdParty++;
if (typeof plugin.installedAt === 'number') {
stats.lastInstalledAt = stats.lastInstalledAt === null
? plugin.installedAt
: Math.max(stats.lastInstalledAt, plugin.installedAt);
}
if (typeof plugin.updatedAt === 'number') {
stats.lastUpdatedAt = stats.lastUpdatedAt === null
? plugin.updatedAt
: Math.max(stats.lastUpdatedAt, plugin.updatedAt);
}
}
return stats;
}
// Snapshot-side aggregation — pure, takes the rows the daemon
// produces from `applied_plugin_snapshots`. Kept in a sister
// function so the route can call both halves and merge on the way
// out.
export interface SnapshotInventoryStats {
total: number;
byStatus: Record<string, number>; // 'fresh' | 'stale'
withProject: number; // project_id IS NOT NULL
withRun: number; // run_id IS NOT NULL
// The oldest applied_at + the newest applied_at (epoch ms) so
// an operator can see 'how far back does our snapshot history
// reach?'.
oldestAppliedAt: number | null;
newestAppliedAt: number | null;
}
export interface SnapshotStatsRow {
status: 'fresh' | 'stale';
project_id: string | null;
run_id: string | null;
applied_at: number;
}
// Plan §3.MM2 — `pluginSourceBuckets()` aggregates installed
// plugins by (sourceKind, source) tuples. Used by the
// `od plugin sources` CLI; lives next to the other stats
// helpers so future audit panels can reuse it.
export interface PluginSourceBucket {
sourceKind: PluginSourceKind | 'unknown';
source: string;
count: number;
plugins: Array<{ id: string; version: string }>;
}
export interface PluginSourceBucketsResult {
total: number;
buckets: PluginSourceBucket[];
}
export function pluginSourceBuckets(plugins: ReadonlyArray<InstalledPluginRecord>): PluginSourceBucketsResult {
const map = new Map<string, PluginSourceBucket>();
for (const p of plugins) {
const sourceKind = (p.sourceKind ?? 'unknown') as PluginSourceBucket['sourceKind'];
const source = p.source ?? '(none)';
const key = `${sourceKind}\t${source}`;
let bucket = map.get(key);
if (!bucket) {
bucket = { sourceKind, source, count: 0, plugins: [] };
map.set(key, bucket);
}
bucket.count += 1;
bucket.plugins.push({ id: p.id, version: p.version });
}
// Sort plugins within bucket by id for deterministic output.
for (const b of map.values()) b.plugins.sort((a, b) => a.id.localeCompare(b.id));
// Sort buckets by descending count, then ascending sourceKind / source.
const buckets = [...map.values()].sort((a, b) => {
if (a.count !== b.count) return b.count - a.count;
if (a.sourceKind !== b.sourceKind) return a.sourceKind.localeCompare(b.sourceKind);
return a.source.localeCompare(b.source);
});
return { total: plugins.length, buckets };
}
export function snapshotInventoryStats(rows: ReadonlyArray<SnapshotStatsRow>): SnapshotInventoryStats {
const stats: SnapshotInventoryStats = {
total: rows.length,
byStatus: {},
withProject: 0,
withRun: 0,
oldestAppliedAt: null,
newestAppliedAt: null,
};
for (const row of rows) {
const status = row.status ?? 'unknown';
stats.byStatus[status] = (stats.byStatus[status] ?? 0) + 1;
if (row.project_id) stats.withProject++;
if (row.run_id) stats.withRun++;
if (typeof row.applied_at === 'number') {
stats.oldestAppliedAt = stats.oldestAppliedAt === null
? row.applied_at
: Math.min(stats.oldestAppliedAt, row.applied_at);
stats.newestAppliedAt = stats.newestAppliedAt === null
? row.applied_at
: Math.max(stats.newestAppliedAt, row.applied_at);
}
}
return stats;
}

View file

@ -0,0 +1,216 @@
// Trust resolver. Spec §5.3 has two tiers — `trusted` and `restricted`.
// Phase 1 keeps the policy minimal:
//
// - Local installs default to `trusted` (the user copied the folder
// here themselves).
// - Anything else (bundled / marketplace / github / url / project) defaults
// to `restricted` until an explicit `od plugin trust <id>` flips it. Phase
// 2A wires the marketplace trust roll-up; we just expose the helpers now.
// - `restricted` plugins ship the prompt:inject capability only. Apply-time
// adds explicit grants (e.g. `mcp:<name>`, `connector:<id>`) onto the
// snapshot; we never widen the registry-stored cache here.
import type { InstalledPluginRecord, PluginManifest, TrustTier } from '@open-design/contracts';
export const TRUSTED_DEFAULT_CAPABILITIES: ReadonlyArray<string> = [
'prompt:inject',
'mcp:*',
'connector:*',
'genui:*',
'pipeline:*',
];
export const RESTRICTED_DEFAULT_CAPABILITIES: ReadonlyArray<string> = ['prompt:inject'];
export function defaultTrustForRecord(record: Pick<InstalledPluginRecord, 'sourceKind'>): TrustTier {
return record.sourceKind === 'local' ? 'trusted' : 'restricted';
}
export function defaultCapabilities(trust: TrustTier): string[] {
return trust === 'trusted'
? Array.from(TRUSTED_DEFAULT_CAPABILITIES)
: Array.from(RESTRICTED_DEFAULT_CAPABILITIES);
}
// Return the capabilities a manifest *requires* to apply cleanly. Apply-time
// grant decisions consult this; the doctor reports under-grants here too.
export function requiredCapabilities(manifest: PluginManifest): string[] {
const required = new Set<string>(['prompt:inject']);
const od = manifest.od;
for (const mcp of od?.context?.mcp ?? []) {
if (mcp?.name) required.add(`mcp:${mcp.name}`);
}
for (const ref of od?.connectors?.required ?? []) {
if (ref?.id) required.add(`connector:${ref.id}`);
}
for (const ref of od?.connectors?.optional ?? []) {
if (ref?.id) required.add(`connector:${ref.id}?`);
}
for (const surface of od?.genui?.surfaces ?? []) {
if (surface?.kind) required.add(`genui:${surface.kind}`);
}
if ((od?.pipeline?.stages?.length ?? 0) > 0) {
required.add('pipeline:*');
}
for (const cap of od?.capabilities ?? []) {
if (typeof cap === 'string' && cap.length > 0) required.add(cap);
}
return Array.from(required.values()).sort();
}
// Compute the granted set Phase 1 applies for a given trust tier and
// manifest. Restricted plugins start at `prompt:inject`; trusted plugins
// receive everything required by their manifest plus the trusted defaults.
export function resolveCapabilitiesGranted(args: {
manifest: PluginManifest;
trust: TrustTier;
}): string[] {
const out = new Set(defaultCapabilities(args.trust));
if (args.trust === 'trusted') {
for (const cap of requiredCapabilities(args.manifest)) {
out.add(stripOptionalSuffix(cap));
}
}
return Array.from(out.values()).sort();
}
function stripOptionalSuffix(cap: string): string {
return cap.endsWith('?') ? cap.slice(0, -1) : cap;
}
// Plan §3.A2 / spec §9.1. The capability vocabulary that a `restricted`
// plugin can be promoted to via `od plugin trust`. Anything outside this
// set is rejected at the HTTP layer.
const KNOWN_TOP_LEVEL_CAPABILITIES = new Set<string>([
'prompt:inject',
'fs:read',
'fs:write',
'mcp',
'subprocess',
'bash',
'network',
'connector',
// Plan §3.K3 / spec §10.3.5 — plugin-bundled React component
// surfaces require an explicit capability so a restricted plugin
// cannot smuggle arbitrary UI through the GenUI layer.
'genui:custom-component',
]);
const SCOPED_CONNECTOR_RE = /^connector:[a-z0-9][a-z0-9_-]*$/;
const SCOPED_MCP_RE = /^mcp:[A-Za-z0-9][A-Za-z0-9._-]*$/;
export interface InvalidCapabilityIssue {
capability: string;
reason: 'unknown' | 'malformed';
}
// Validate a list of capability strings against the spec §5.3 vocabulary.
// Returns the (deduped) accepted list plus issues for anything rejected;
// unknown shapes are NOT silently dropped — the caller must surface them.
export function validateCapabilityList(
raw: unknown,
): { accepted: string[]; rejected: InvalidCapabilityIssue[] } {
const accepted: string[] = [];
const rejected: InvalidCapabilityIssue[] = [];
const seen = new Set<string>();
if (!Array.isArray(raw)) {
return { accepted, rejected };
}
for (const item of raw) {
if (typeof item !== 'string') {
rejected.push({ capability: String(item), reason: 'malformed' });
continue;
}
const trimmed = item.trim();
if (!trimmed) continue;
if (seen.has(trimmed)) continue;
if (KNOWN_TOP_LEVEL_CAPABILITIES.has(trimmed)) {
seen.add(trimmed);
accepted.push(trimmed);
continue;
}
if (SCOPED_CONNECTOR_RE.test(trimmed) || SCOPED_MCP_RE.test(trimmed)) {
seen.add(trimmed);
accepted.push(trimmed);
continue;
}
rejected.push({ capability: trimmed, reason: 'unknown' });
}
return { accepted, rejected };
}
// Persist a capability grant. Reads the existing union from
// `installed_plugins.capabilities_granted`, merges with the request,
// and writes the deduped sorted union back. Idempotent — re-granting
// the same set is a no-op. Returns the resulting list.
export function grantCapabilities(args: {
db: import('better-sqlite3').Database;
pluginId: string;
capabilities: string[];
}): string[] {
const row = args.db
.prepare(`SELECT capabilities_granted FROM installed_plugins WHERE id = ?`)
.get(args.pluginId) as { capabilities_granted?: string } | undefined;
if (!row) {
throw new Error(`plugin not found: ${args.pluginId}`);
}
let existing: string[] = [];
try {
const parsed = JSON.parse(row.capabilities_granted ?? '[]') as unknown;
if (Array.isArray(parsed)) {
existing = parsed.filter((c): c is string => typeof c === 'string');
}
} catch {
existing = [];
}
const merged = Array.from(new Set([...existing, ...args.capabilities])).sort();
const now = Date.now();
args.db
.prepare(
`UPDATE installed_plugins
SET capabilities_granted = ?, updated_at = ?
WHERE id = ?`,
)
.run(JSON.stringify(merged), now, args.pluginId);
return merged;
}
// Revoke previously-granted capabilities. Subset of `grantCapabilities`
// but subtracts. The implicit `prompt:inject` floor is preserved so a
// trusted-by-default plugin never falls below the spec §5.3 minimum.
export function revokeCapabilities(args: {
db: import('better-sqlite3').Database;
pluginId: string;
capabilities: string[];
}): string[] {
const row = args.db
.prepare(`SELECT capabilities_granted FROM installed_plugins WHERE id = ?`)
.get(args.pluginId) as { capabilities_granted?: string } | undefined;
if (!row) {
throw new Error(`plugin not found: ${args.pluginId}`);
}
let existing: string[] = [];
try {
const parsed = JSON.parse(row.capabilities_granted ?? '[]') as unknown;
if (Array.isArray(parsed)) {
existing = parsed.filter((c): c is string => typeof c === 'string');
}
} catch {
existing = [];
}
const drop = new Set(args.capabilities);
drop.delete('prompt:inject');
const next = existing.filter((c) => !drop.has(c));
if (!next.includes('prompt:inject')) next.push('prompt:inject');
next.sort();
const now = Date.now();
args.db
.prepare(
`UPDATE installed_plugins
SET capabilities_granted = ?, updated_at = ?
WHERE id = ?`,
)
.run(JSON.stringify(next), now, args.pluginId);
return next;
}

View file

@ -0,0 +1,211 @@
// `until` expression evaluator (spec §10.1 / §10.2). Closed vocabulary;
// not arbitrary JS. The grammar is a comma-/`||`-separated disjunction of
// AND-joined comparisons over four known signal variables:
//
// critique.score number — last critique-theater dim score (0..5)
// iterations number — completed devloop iterations on this stage
// user.confirmed boolean — last `confirmation` surface answer
// preview.ok boolean — last `live-artifact` preview load result
//
// Comparison operators: == != >= <= > < (booleans support ==/!= only).
// Boolean literals: true / false. Number literals: any JSON number.
//
// The evaluator is intentionally tiny so `od plugin doctor` can syntax-check
// at install time without booting an interpreter, and the daemon refuses
// to execute a stage whose `until` does not parse.
export type SignalKind = 'number' | 'boolean';
export interface UntilSignals {
'critique.score'?: number | undefined;
'iterations'?: number | undefined;
'user.confirmed'?: boolean | undefined;
'preview.ok'?: boolean | undefined;
// Plan §3.N1 / spec §22.4 — promoted by the build-test atom
// (Phase 7 entry slice). Lets a plan write
// `until: 'build.passing && tests.passing'` directly instead of
// collapsing pass/fail into critique.score.
'build.passing'?: boolean | undefined;
'tests.passing'?: boolean | undefined;
}
const SIGNAL_KINDS: Record<keyof UntilSignals, SignalKind> = {
'critique.score': 'number',
'iterations': 'number',
'user.confirmed': 'boolean',
'preview.ok': 'boolean',
'build.passing': 'boolean',
'tests.passing': 'boolean',
};
export type UntilOp = '==' | '!=' | '>=' | '<=' | '>' | '<';
export interface UntilComparison {
signal: keyof UntilSignals;
op: UntilOp;
value: number | boolean;
}
// Disjunction of conjunctions: at least one outer term must hold; each
// inner term is a comparison over a known signal.
export interface UntilExpression {
any: UntilComparison[][];
}
export class UntilSyntaxError extends Error {
constructor(message: string) {
super(message);
this.name = 'UntilSyntaxError';
}
}
export function parseUntil(source: string): UntilExpression {
const trimmed = source.trim();
if (trimmed.length === 0) {
throw new UntilSyntaxError('empty until expression');
}
const ors = splitTopLevel(trimmed, '||');
const any: UntilComparison[][] = [];
for (const orTerm of ors) {
const ands = splitTopLevel(orTerm, '&&');
const inner: UntilComparison[] = [];
for (const andTerm of ands) {
inner.push(parseComparison(andTerm.trim()));
}
if (inner.length === 0) {
throw new UntilSyntaxError(`empty conjunction in "${source}"`);
}
any.push(inner);
}
if (any.length === 0) {
throw new UntilSyntaxError(`no terms in "${source}"`);
}
return { any };
}
function splitTopLevel(input: string, sep: '||' | '&&'): string[] {
const parts: string[] = [];
let cursor = 0;
let depth = 0;
for (let i = 0; i < input.length - 1; i += 1) {
const ch = input[i] as string;
if (ch === '(') depth += 1;
else if (ch === ')') depth -= 1;
else if (depth === 0 && ch === sep[0] && input[i + 1] === sep[1]) {
parts.push(input.slice(cursor, i));
cursor = i + 2;
i += 1;
}
}
parts.push(input.slice(cursor));
return parts.map((p) => p.trim()).filter((p) => p.length > 0);
}
function parseComparison(raw: string): UntilComparison {
const expression = stripOuterParens(raw).trim();
const opMatch = expression.match(/(==|!=|>=|<=|>|<)/);
if (!opMatch || opMatch.index === undefined) {
throw new UntilSyntaxError(`expected comparison operator in "${raw}"`);
}
const op = opMatch[0] as UntilOp;
const lhs = expression.slice(0, opMatch.index).trim();
const rhs = expression.slice(opMatch.index + op.length).trim();
if (!(lhs in SIGNAL_KINDS)) {
throw new UntilSyntaxError(
`unknown signal "${lhs}" — supported: ${Object.keys(SIGNAL_KINDS).join(', ')}`,
);
}
const signal = lhs as keyof UntilSignals;
const kind = SIGNAL_KINDS[signal];
let value: number | boolean;
if (kind === 'boolean') {
if (rhs !== 'true' && rhs !== 'false') {
throw new UntilSyntaxError(`signal "${signal}" expects true/false, got "${rhs}"`);
}
if (op !== '==' && op !== '!=') {
throw new UntilSyntaxError(
`boolean signal "${signal}" only supports == and !=, got "${op}"`,
);
}
value = rhs === 'true';
} else {
const parsed = Number(rhs);
if (!Number.isFinite(parsed)) {
throw new UntilSyntaxError(`signal "${signal}" expects a number, got "${rhs}"`);
}
value = parsed;
}
return { signal, op, value };
}
function stripOuterParens(input: string): string {
let s = input;
while (s.startsWith('(') && s.endsWith(')')) {
let depth = 0;
let isOuter = true;
for (let i = 0; i < s.length - 1; i += 1) {
const ch = s[i];
if (ch === '(') depth += 1;
else if (ch === ')') depth -= 1;
if (depth === 0 && i < s.length - 1) {
isOuter = false;
break;
}
}
if (!isOuter) return s;
s = s.slice(1, -1).trim();
}
return s;
}
export interface EvaluationResult {
satisfied: boolean;
// The first matching conjunction's terms — useful for debugging /
// event payloads. Empty when no term holds.
matched: UntilComparison[];
}
export function evaluateUntil(
expression: UntilExpression,
signals: UntilSignals,
): EvaluationResult {
for (const conjunction of expression.any) {
let allHold = true;
for (const term of conjunction) {
if (!evaluateTerm(term, signals)) {
allHold = false;
break;
}
}
if (allHold) return { satisfied: true, matched: conjunction };
}
return { satisfied: false, matched: [] };
}
function evaluateTerm(term: UntilComparison, signals: UntilSignals): boolean {
const left = signals[term.signal];
if (left === undefined) return false;
if (typeof term.value === 'boolean') {
if (typeof left !== 'boolean') return false;
return term.op === '==' ? left === term.value : left !== term.value;
}
if (typeof left !== 'number') return false;
const right = term.value;
switch (term.op) {
case '==': return left === right;
case '!=': return left !== right;
case '>=': return left >= right;
case '<=': return left <= right;
case '>': return left > right;
case '<': return left < right;
}
}
export function isParseableUntil(source: string): boolean {
try {
parseUntil(source);
return true;
} catch {
return false;
}
}

View file

@ -0,0 +1,114 @@
// Phase 4 / spec §11.5 / plan §3.W1 — author-side plugin validation.
//
// Pre-install lint pass: takes a path to a plugin folder on disk
// (typically the author's local working dir) and returns the same
// DoctorReport shape the post-install `od plugin doctor <id>`
// command emits.
//
// The lift from `od plugin doctor`:
// - reads the folder via the same resolvePluginFolder() the
// installer uses, so manifest parsing is byte-equal,
// - calls doctorPlugin() with the supplied registry view (which
// the CLI fetches from the daemon when reachable; falls back
// to an empty registry so the lint runs offline),
// - skips the `snapshot-stale` cross-check (no SQLite involved
// because nothing is installed yet).
//
// Rationale: spec §16 Phase 4 ships `od plugin scaffold`, `od
// plugin export`, `od plugin publish` for the author tooling slice.
// `od plugin validate` closes the loop: the author can run lint
// before pushing to a marketplace catalog, without installing into
// their own daemon (which would dirty the registry table).
import path from 'node:path';
import type { RegistryView } from '@open-design/plugin-runtime';
import { doctorPlugin, type DoctorReport, type Diagnostic } from './doctor.js';
import { resolvePluginFolder } from './registry.js';
import type { ConnectorProbe } from './connector-gate.js';
export interface ValidatePluginFolderInput {
// Path to the plugin folder. Must contain at least one of
// `open-design.json` / `SKILL.md` / `.claude-plugin/plugin.json`
// for resolvePluginFolder() to succeed.
folder: string;
// Optional pre-fetched registry. Tests pass a stub; CLI fetches
// from a reachable daemon. Empty / undefined means the validator
// skips registry-bound ref checks (skills / DS / craft refs in
// the manifest just emit warnings).
registry?: RegistryView;
// Optional connector probe. Same semantics as the post-install
// doctor.
connectorProbe?: ConnectorProbe;
}
export interface ValidatePluginFolderResult {
ok: boolean;
// Warnings/errors raised during folder resolution (manifest
// parse + adapter merge), separate from the doctorPlugin pass.
resolveErrors: string[];
resolveWarnings: string[];
// Doctor report; absent only when resolve failed.
doctor?: DoctorReport;
// Echoed for the CLI's audit / JSON output.
folder: string;
}
const EMPTY_REGISTRY: RegistryView = {
skills: [],
designSystems: [],
craft: [],
atoms: [],
};
export async function validatePluginFolder(
input: ValidatePluginFolderInput,
): Promise<ValidatePluginFolderResult> {
const folder = path.resolve(input.folder);
const folderId = path.basename(folder).toLowerCase();
// Match the installer's safe-id check so the author sees the
// same rejection they'll get at install time.
const probe = await resolvePluginFolder({
folder,
folderId,
sourceKind: 'local',
source: folder,
trust: 'restricted',
});
if (!probe.ok) {
return {
ok: false,
resolveErrors: probe.errors,
resolveWarnings: probe.warnings,
folder,
};
}
const doctor = doctorPlugin(probe.record, input.registry ?? EMPTY_REGISTRY, {
warnOnMissingRefs: !!input.registry,
...(input.connectorProbe ? { connectorProbe: input.connectorProbe } : {}),
});
return {
ok: probe.warnings.length > 0 ? doctor.ok : doctor.ok,
resolveErrors: [],
resolveWarnings: probe.warnings,
doctor,
folder,
};
}
// Helper a CLI / API renderer can use to flatten the result into a
// flat list the output formatter walks. Useful when the consumer
// doesn't want to special-case resolve vs. doctor diagnostics.
export function flattenValidationDiagnostics(result: ValidatePluginFolderResult): Diagnostic[] {
const out: Diagnostic[] = [];
for (const err of result.resolveErrors) {
out.push({ severity: 'error', code: 'manifest.resolve', message: err });
}
for (const warn of result.resolveWarnings) {
out.push({ severity: 'warning', code: 'manifest.resolve', message: warn });
}
if (result.doctor) {
for (const issue of result.doctor.issues) out.push(issue);
}
return out;
}

View file

@ -0,0 +1,163 @@
// Phase 4 / spec §11.5 / plan §3.FF1 — `od plugin verify` orchestrator.
//
// Pure helper that aggregates three independent checks into one
// CI-friendly pass/fail report:
//
// doctor — manifest + atom + ref lint (existing helper)
// simulate — pipeline convergence dry-run for every
// 'until' expression in the plugin (existing helper)
// canon — optional byte-equality check against a committed
// expected-block.md fixture
//
// The orchestrator is pure relative to its inputs: doctor + simulate
// reports come in already-computed (the CLI runs them and feeds the
// outputs in). canon comparison happens here because it's a string
// equality + diff format. Callers decide which checks to run via the
// `checks` flag set; the orchestrator skips disabled checks cleanly.
//
// Sister of plugins/doctor.ts, plugins/simulate.ts. Lives separately
// so the verify report shape can evolve without churning either of
// the underlying helpers.
import type { DoctorReport } from './doctor.js';
import type { SimulatePipelineResult } from './simulate.js';
export type VerifyCheckId = 'doctor' | 'simulate' | 'canon';
export interface VerifyConfig {
// Subset of checks to run. Default: all.
enabled?: ReadonlyArray<VerifyCheckId>;
// Plan §3.HH1 — when true, doctor warnings count as failures.
// Mirrors `od plugin doctor --strict`. Default false.
strict?: boolean;
// Optional simulate inputs — if absent, simulate is skipped (the
// verify config doesn't auto-build a default signal map; the
// author must opt in by declaring expected signals).
simulate?: {
signals?: Record<string, string | number | boolean>;
iterationCap?: number;
};
// Optional canon byte-fixture. When set, verify expects the
// CLI to attach the daemon's canon output via VerifyInput.canon
// and the on-disk expected text via VerifyInput.canonExpected.
canon?: {
snapshotId?: string;
fixturePath?: string;
};
}
export interface VerifyInput {
config: VerifyConfig;
// Pre-computed reports (CLI runs the daemon hits + feeds them in).
doctor?: DoctorReport;
simulate?: SimulatePipelineResult;
canon?: string;
canonExpected?: string;
}
export interface VerifyCheckOutcome {
check: VerifyCheckId;
// 'passed' | 'failed' | 'skipped' (when not in enabled set OR
// missing inputs). 'unsupported' = the check was enabled but the
// CLI failed to fetch the underlying report (e.g. plugin not
// installed); we surface 'unsupported' so the verify report
// distinguishes 'opted out' from 'tried and failed'.
status: 'passed' | 'failed' | 'skipped' | 'unsupported';
// One-line human summary (e.g. 'doctor: 0 errors, 2 warnings'
// or 'simulate: all-converged across 3 stages, 5 iterations').
summary: string;
// Optional structured details (counts, per-line diff entries,
// etc.) the JSON renderer surfaces.
details?: Record<string, unknown>;
}
export interface VerifyReport {
pluginId?: string;
// Overall pass/fail: passes if every enabled check passes and no
// check is 'unsupported'. Skipped checks don't fail the report.
passed: boolean;
outcomes: VerifyCheckOutcome[];
}
const DEFAULT_CHECKS: ReadonlyArray<VerifyCheckId> = ['doctor', 'simulate', 'canon'];
export function verifyPlugin(input: VerifyInput): VerifyReport {
const enabled = new Set<VerifyCheckId>(
(input.config.enabled ?? DEFAULT_CHECKS).filter((c): c is VerifyCheckId =>
c === 'doctor' || c === 'simulate' || c === 'canon',
),
);
const outcomes: VerifyCheckOutcome[] = [];
// doctor
if (!enabled.has('doctor')) {
outcomes.push({ check: 'doctor', status: 'skipped', summary: 'doctor: skipped (not in enabled set)' });
} else if (!input.doctor) {
outcomes.push({ check: 'doctor', status: 'unsupported', summary: 'doctor: report missing (CLI did not fetch)' });
} else {
const errors = input.doctor.issues.filter((i) => i.severity === 'error').length;
const warnings = input.doctor.issues.filter((i) => i.severity === 'warning').length;
// Plan §3.HH1: in strict mode, warnings fail the check too.
const strict = input.config.strict === true;
const failed = errors > 0 || (strict && warnings > 0);
const status: VerifyCheckOutcome['status'] = failed ? 'failed' : 'passed';
const summary = strict && warnings > 0 && errors === 0
? `doctor: 0 errors, ${warnings} warning${warnings === 1 ? '' : 's'} (strict mode \u2014 warnings fail the check)`
: `doctor: ${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}`;
outcomes.push({
check: 'doctor',
status,
summary,
details: { errors, warnings, ok: input.doctor.ok, freshDigest: input.doctor.freshDigest, strict },
});
}
// simulate
if (!enabled.has('simulate')) {
outcomes.push({ check: 'simulate', status: 'skipped', summary: 'simulate: skipped (not in enabled set)' });
} else if (!input.simulate) {
outcomes.push({ check: 'simulate', status: 'unsupported', summary: 'simulate: report missing (CLI did not run pipeline simulator)' });
} else {
const failing = input.simulate.outcome === 'cap-hit' || input.simulate.outcome === 'unparsable';
const status: VerifyCheckOutcome['status'] = failing ? 'failed' : 'passed';
outcomes.push({
check: 'simulate',
status,
summary: `simulate: ${input.simulate.outcome}, ${input.simulate.totalIterations} iteration${input.simulate.totalIterations === 1 ? '' : 's'} across ${input.simulate.stages.length} stage${input.simulate.stages.length === 1 ? '' : 's'}`,
details: {
outcome: input.simulate.outcome,
totalIterations: input.simulate.totalIterations,
stages: input.simulate.stages.map((s) => ({ stageId: s.stageId, outcome: s.outcome, iterations: s.iterations })),
},
});
}
// canon — only meaningful when both inputs are present.
if (!enabled.has('canon')) {
outcomes.push({ check: 'canon', status: 'skipped', summary: 'canon: skipped (not in enabled set)' });
} else if (typeof input.canon !== 'string' || typeof input.canonExpected !== 'string') {
outcomes.push({
check: 'canon',
status: 'skipped',
summary: 'canon: skipped (no fixture supplied; declare config.canon.fixturePath)',
});
} else if (input.canon === input.canonExpected) {
outcomes.push({
check: 'canon',
status: 'passed',
summary: `canon: byte-equal (${input.canon.length} bytes)`,
});
} else {
outcomes.push({
check: 'canon',
status: 'failed',
summary: `canon: mismatch (expected ${input.canonExpected.length} bytes, got ${input.canon.length} bytes)`,
details: { expectedLength: input.canonExpected.length, actualLength: input.canon.length },
});
}
// Overall pass: every enabled outcome passed, and no 'unsupported'.
const passed = outcomes.every((o) => o.status === 'passed' || o.status === 'skipped');
return { passed, outcomes };
}

View file

@ -1,13 +1,25 @@
import type { Express } from 'express';
import {
defaultScenarioPluginIdForKind,
type PluginManifest,
} from '@open-design/contracts';
import { ArtifactRegressionError } from './artifact-stub-guard.js';
import { listDesignSystems } from './design-systems.js';
import {
FIRST_PARTY_ATOMS,
getInstalledPlugin,
listInstalledPlugins,
resolvePluginSnapshot,
} from './plugins/index.js';
import type { RouteDeps } from './server-context.js';
import { listSkills } from './skills.js';
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry'> {}
export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDeps) {
const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http;
const { PROJECTS_DIR } = ctx.paths;
const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR } = ctx.paths;
const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore;
const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles;
const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations;
@ -15,6 +27,55 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
const { listLatestProjectRunStatuses, listProjectsAwaitingInput, normalizeProjectDisplayStatus, composeProjectDisplayStatus, listProjects } = ctx.status;
const { subscribeFileEvents, activeProjectEventSinks } = ctx.events;
const { randomId } = ctx.ids;
async function loadPluginRegistryView() {
const [skills, designSystems] = await Promise.all([
listSkills(SKILLS_DIR),
listDesignSystems(DESIGN_SYSTEMS_DIR),
]);
return {
skills: skills.map((s) => ({ id: s.id, title: s.name, description: s.description })),
designSystems: designSystems.map((d) => ({ id: d.id, title: d.title })),
craft: [],
atoms: FIRST_PARTY_ATOMS.map((a) => ({ id: a.id, label: a.label })),
scenarios: collectBundledScenarios(),
};
}
function collectBundledScenarios() {
type ScenarioEntry = {
id: string;
taskKind: 'new-generation' | 'figma-migration' | 'code-migration' | 'tune-collab';
pipeline: NonNullable<NonNullable<PluginManifest['od']>['pipeline']>;
};
const byTaskKind = new Map<ScenarioEntry['taskKind'], ScenarioEntry>();
try {
const all = listInstalledPlugins(db);
for (const row of all) {
if (row.sourceKind !== 'bundled') continue;
const od = row.manifest.od;
if (!od || od.kind !== 'scenario') continue;
if (!od.pipeline || !Array.isArray(od.pipeline.stages) || od.pipeline.stages.length === 0) continue;
const taskKind = (od.taskKind ?? 'new-generation') as ScenarioEntry['taskKind'];
if (
taskKind !== 'new-generation' &&
taskKind !== 'figma-migration' &&
taskKind !== 'code-migration' &&
taskKind !== 'tune-collab'
) {
continue;
}
const entry: ScenarioEntry = { id: row.id, taskKind, pipeline: od.pipeline };
const existing = byTaskKind.get(taskKind);
if (!existing || entry.id === `od-${taskKind}`) {
byTaskKind.set(taskKind, entry);
}
}
} catch {
return [];
}
return Array.from(byTaskKind.values());
}
app.get('/api/projects', (_req, res) => {
try {
const latestRunStatuses = listLatestProjectRunStatuses(db);
@ -143,6 +204,48 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
createdAt: now,
updatedAt: now,
});
const explicitPlugin =
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
? true
: typeof req.body?.appliedPluginSnapshotId === 'string'
&& req.body.appliedPluginSnapshotId.trim().length > 0;
let resolveBody =
explicitPlugin ? (req.body as Record<string, unknown>) : null;
if (!resolveBody) {
const fallbackPluginId = defaultScenarioPluginIdForKind(
projectMetadata?.kind,
);
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
resolveBody = { ...(req.body || {}), pluginId: fallbackPluginId };
}
}
let resolvedSnapshot = null;
if (resolveBody) {
const registry = await loadPluginRegistryView();
const resolved = resolvePluginSnapshot({
db,
body: resolveBody,
projectId: id,
conversationId: cid,
registry,
activeProjectDesignSystem:
typeof designSystemId === 'string' && designSystemId.length > 0
? { id: designSystemId }
: undefined,
});
if (resolved && !resolved.ok) {
if (!explicitPlugin) {
console.warn(
`[plugins] default-scenario fallback skipped for project ${id}: ${resolved.body?.error?.code ?? 'unknown'}`,
);
} else {
return res.status(resolved.status).json(resolved.body);
}
} else {
resolvedSnapshot = resolved;
}
}
// For "from template" projects, seed the chosen template's snapshot
// HTML into the new project folder so the agent can Read/edit files
// on disk (the system prompt also embeds them, but a real on-disk
@ -179,7 +282,13 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
}
}
/** @type {import('@open-design/contracts').CreateProjectResponse} */
const body = { project, conversationId: cid };
const body = {
project: resolvedSnapshot?.ok ? getProject(db, id) ?? project : project,
conversationId: cid,
...(resolvedSnapshot?.ok
? { appliedPluginSnapshotId: resolvedSnapshot.snapshotId }
: {}),
};
res.json(body);
} catch (err: any) {
sendApiError(res, 400, 'BAD_REQUEST', String(err));

View file

@ -66,9 +66,19 @@ export const DEFAULT_AWAIT_WRITE_FINISH = {
};
const registry = new Map<string, WatcherEntry>();
const PREFERS_POLLING_IN_TESTS = process.env.NODE_ENV === 'test';
function makeEntry(dir: string, opts: Required<Pick<ProjectWatcherOptions, 'ignored' | 'awaitWriteFinish'>>): WatcherEntry {
const watcher = chokidar.watch(dir, {
function isPollingFallbackError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
return code === 'EMFILE' || code === 'ENOSPC';
}
function createWatcher(
dir: string,
opts: Required<Pick<ProjectWatcherOptions, 'ignored' | 'awaitWriteFinish'>>,
usePolling: boolean,
): FSWatcher {
const watcherOptions = {
ignored: opts.ignored,
ignoreInitial: true,
awaitWriteFinish: opts.awaitWriteFinish,
@ -77,30 +87,32 @@ function makeEntry(dir: string, opts: Required<Pick<ProjectWatcherOptions, 'igno
// path ignore predicate keeps emitted events project-scoped, an unhandled
// symlink would still cost descriptors and surface external FS activity.
followSymlinks: false,
});
// chokidar's FSWatcher is an EventEmitter. Without an `error` listener,
// transient FS faults (ENOSPC, EPERM, EMFILE on saturated inotify watches)
// would surface as unhandled exceptions and could crash the daemon — taking
// every other route down with it. Log and keep the watcher alive; refcount
// cleanup is unaffected.
watcher.on('error', (err) => {
if (process.env.NODE_ENV === 'development') {
console.warn('[project-watchers] chokidar error in', dir, err);
}
});
usePolling,
...(usePolling ? { interval: 100, binaryInterval: 300 } : {}),
};
return chokidar.watch(dir, watcherOptions);
}
function makeEntry(dir: string, opts: Required<Pick<ProjectWatcherOptions, 'ignored' | 'awaitWriteFinish'>>): WatcherEntry {
let resolveReady: () => void;
const ready = new Promise<void>((resolve) => { resolveReady = resolve; });
watcher.once('ready', () => resolveReady());
let readyResolved = false;
const subscribers = new Set<ProjectWatchCallback>();
const entry: WatcherEntry = {
dir,
watcher,
watcher: createWatcher(dir, opts, PREFERS_POLLING_IN_TESTS),
ready,
subscribers: new Set(),
subscribers,
closing: null,
};
let usingPollingFallback = PREFERS_POLLING_IN_TESTS;
let switchingToPolling = false;
const resolveReadyOnce = () => {
if (readyResolved) return;
readyResolved = true;
resolveReady();
};
const broadcast = (kind: ProjectWatchKind) => (absPath: string) => {
const rel = path.relative(dir, absPath);
@ -119,9 +131,35 @@ function makeEntry(dir: string, opts: Required<Pick<ProjectWatcherOptions, 'igno
}
};
watcher.on('add', broadcast('add'));
watcher.on('change', broadcast('change'));
watcher.on('unlink', broadcast('unlink'));
const attachWatcher = (watcher: FSWatcher) => {
watcher.once('ready', () => resolveReadyOnce());
watcher.on('add', broadcast('add'));
watcher.on('change', broadcast('change'));
watcher.on('unlink', broadcast('unlink'));
// chokidar's FSWatcher is an EventEmitter. Without an `error` listener,
// transient FS faults (ENOSPC, EPERM, EMFILE on saturated inotify watches)
// would surface as unhandled exceptions and could crash the daemon.
watcher.on('error', (err) => {
if (isPollingFallbackError(err) && !usingPollingFallback && !switchingToPolling) {
switchingToPolling = true;
const next = createWatcher(dir, opts, true);
usingPollingFallback = true;
entry.watcher = next;
attachWatcher(next);
void watcher.close().catch(() => {});
switchingToPolling = false;
return;
}
if (process.env.NODE_ENV === 'development') {
console.warn('[project-watchers] chokidar error in', dir, err);
}
// A watcher that fails before it reaches ready would otherwise hang every
// caller awaiting `sub.ready`.
resolveReadyOnce();
});
};
attachWatcher(entry.watcher);
return entry;
}
@ -152,8 +190,8 @@ export function subscribe(projectsRoot: string, projectId: string, onEvent: Proj
if (!entry) {
const factory = opts._watcherFactory || makeEntry;
entry = factory(dir, {
ignored: opts.ignored || makeIgnored(dir),
awaitWriteFinish: opts.awaitWriteFinish || DEFAULT_AWAIT_WRITE_FINISH,
ignored: opts.ignored ?? makeIgnored(dir),
awaitWriteFinish: opts.awaitWriteFinish ?? DEFAULT_AWAIT_WRITE_FINISH,
});
registry.set(key, entry);
}

View file

@ -96,11 +96,22 @@ export const DECK_SKELETON_HTML = `<!doctype html>
.slide {
position: absolute;
inset: 0;
display: none;
flex-direction: column;
overflow: hidden;
}
.slide.active { display: flex; }
/* Visibility toggle hardened with :not(.active) + !important so cascade
order can't break it. The previous \`.slide { display:none }\` rule
lost the cascade whenever a per-slide variant class (e.g.
\`.s-cold { display:grid }\`) was declared after it on the same
element every slide silently became visible at once. The
\`!important\` is a belt-and-suspenders against agent code that adds
\`!important\` on variant classes too. */
.slide:not(.active) { display: none !important; }
/* The active default uses :where() so it has zero specificity. Per-slide
variant classes like \`.s-cold { display:grid }\` or
\`.s-magazine { display:block }\` can override the default flex layout
just by declaring \`display\` — no need for the variant to be more
specific. The hide rule above still wins for inactive slides. */
:where(.slide.active) { display: flex; flex-direction: column; }
/* Chrome counter + prev/next live outside the scaled stage so they
don't shrink with it. Do not relocate them inside .deck-stage. */
@ -349,7 +360,7 @@ These are the failure patterns we just spent days debugging. Each one looks "equ
- Don't use \`document.addEventListener('keydown', )\` alone. Inside an iframe, focus is sometimes on window. The framework adds capture-phase listeners on **both** targets — replacing this with a single listener silently swallows arrow keys.
- Don't replace the localStorage key, the slide-visibility toggle (\`.slide.active\`), or the counter element IDs (\`#deck-cur\`, \`#deck-total\`, \`#deck-prev\`, \`#deck-next\`). The framework reads them by ID.
- Don't put the prev/next buttons or the counter **inside** \`.deck-stage\`. They must live outside the scaled element so they stay legible at any viewport size.
- Don't redefine \`.slide { display: ... }\` in your per-deck styles. The framework uses \`display: none\` / \`display: flex\` to toggle slides; overriding it breaks navigation.
- Don't redefine \`.slide\`, \`.slide.active\`, or \`.slide:not(.active)\` directly. The framework owns the visibility toggle through those exact selectors. If you want a non-flex layout on a slide, **add a variant class to the same \`<section class="slide …">\` element** (e.g. \`.s-cold\`, \`.s-magazine\`) and declare \`display: grid\` / \`display: block\` on the variant. The framework's active default is wrapped in \`:where(...)\` so it has zero specificity — your variant always wins for the active slide. Variant classes do NOT need to be more specific than \`.slide.active\`. (The inactive-hide rule still wins because it uses \`:not(.active) { display: none !important; }\`.)
- Don't strip or "tidy" the \`@media print\` block. It is how Share → PDF stitches every slide into a multi-page document. Without it, PDF export collapses to a single screenshot.
## Why this matters (so you can judge edge cases)
@ -364,6 +375,36 @@ Each \`<section class="slide" data-screen-label="NN Title">\` is one slide rende
Real copy only no lorem ipsum, no invented metrics, no generic emoji icon rows. If you don't have a value, leave a short honest placeholder.
## Density and overflow discipline (the #1 cause of ugly decks)
Even with the visibility toggle working, slides go ugly when content overflows the 1920×1080 canvas. Specific failure modes that ship today:
- Title slides with a display headline 160px **plus** a multi-line subtitle/deck paragraph **plus** an absolutely-positioned \`.footer\` at \`bottom: ~56px\`. The flow content grows downward, the absolute footer occupies the bottom band, and the two collide in the last ~100px of the slide.
- Stat slides with three numbers + three captions + a footer. Split into three stat slides the framework counts slides for you, more slides cost nothing.
- "Magazine spread" attempts that pack masthead + display headline + body grid + sidebar + absolute footer all into a single 1080px slide.
Rules non-negotiable:
1. **Display headlines on cover/title slides: max ~140px font-size, max 8 words, max 3 lines.** If the headline doesn't fit those bounds, the slide is the wrong shape — split it, don't shrink the font and pack more in.
2. **Reserve a footer safe-zone.** If you use \`.footer { position: absolute; bottom: Npx; }\`, flow content above the footer must stop at least 80px before \`1080 footer_height N\`. Practically: don't let flow content extend into the bottom 200px of the slide. Easiest enforcement: make the slide's main content area its own \`<div style="height: 760px;">\` (or \`max-height\`), and the footer absolute below it.
3. **Body slides: 3 paragraphs, 56ch lead text width, 12 words per line.**
4. **One idea per slide.** Two ideas = two slides.
## Pre-emit self-check run this BEFORE writing the \`<artifact>\` tag
For every \`<section class="slide">\`, mentally render at 1920×1080 and answer:
- [ ] Does the slide's content fit inside the canvas without clipping or overflowing the bottom?
- [ ] If there's an absolutely-positioned footer/header, does flow content stop before the footer's reserved band? (See Rule 2 above.)
- [ ] Is the display headline 140px and 8 words?
- [ ] Does the slide carry one big idea? (No mashed-together masthead + display headline + subtitle + absolute footer + sidebar.)
If any answer is "no", redesign the slide BEFORE emitting. Decks that overflow are the most common single failure mode reported by users; the user has rejected one before and will reject one again.
## Prefer the simple-deck skill's layout vocabulary when reachable
If \`plugins/_official/examples/simple-deck/assets/template.html\` and its \`references/layouts.md\` are readable from the project workspace, **prefer those layouts over inventing your own**. The simple-deck skill ships eight paste-ready slide skeletons (cover, body, big-stat, three-point row, pipeline, dark quote, before/after, closing) with tested type scales, density rules, and a P0/P1/P2 checklist. Re-inventing those layouts is the source of most density / overflow bugs the framework can't catch.
## Canonical skeleton (this is exactly what the file you write looks like)
\`\`\`html

View file

@ -34,6 +34,40 @@ Three hard rules govern the start of every new design task. They are not optiona
When the user opens a new project or sends a fresh design brief, your **very first output** is one short prose line + a \`<question-form>\` block. Nothing else. No file reads. No Bash. No TodoWrite. No extended thinking. The form is your time-to-first-byte.
Default-router exception: when the Active plugin / Active skill is \`od-default\` or "Default design router", replace the generic \`discovery\` form with the exact \`<question-form id="task-type">\` form below on turn 1. Do not rename, tailor, drop, reorder, or rewrite these task type options; the user did not choose a Home chip yet, so this form is the missing chip selection. After the user answers \`[form answers — task-type]\`, treat the chosen task type as the route, then continue with the normal discovery / plan / generate / critique flow for that type.
\`\`\`
<question-form id="task-type" title="Choose the task type">
{
"description": "I will route the free-form prompt through the right Open Design workflow.",
"questions": [
{
"id": "taskType",
"label": "What should I build?",
"type": "radio",
"required": true,
"options": [
"Prototype",
"Live artifact",
"Slide deck",
"Image",
"Video",
"HyperFrames",
"Audio",
"Other"
]
},
{
"id": "constraints",
"label": "Any important constraints?",
"type": "textarea",
"placeholder": "Audience, brand, format, length, aspect ratio, references, things to avoid..."
}
]
}
</question-form>
\`\`\`
\`\`\`
<question-form id="discovery" title="Quick brief — 30 seconds">
{

View file

@ -122,6 +122,11 @@ type ProjectMetadata = {
url?: string | null;
} | null;
} | null;
contextPlugins?: Array<{
id?: string | null;
title?: string | null;
description?: string | null;
}> | null;
};
type ProjectTemplate = { name: string; description?: string | null; files: Array<{ name: string; content: string }> };
type AudioVoiceOption = {
@ -137,6 +142,20 @@ export const SKIP_DISCOVERY_BRIEF_OVERRIDE = `# Automated project mode — skip
This project was created through the daemon API with \`skipDiscoveryBrief: true\`. Override the discovery rules below: do NOT emit \`<question-form id="discovery">\`, do NOT show "Quick brief — 30 seconds", and do NOT ask a first-turn clarification form. Treat the user's first message and project metadata as the brief, then proceed directly to planning/building under the normal artifact workflow. Ask at most one concise follow-up only if a required detail is impossible to infer safely.`;
const ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE = `
---
## Active design system visual direction
Active design system exception: the active design system is the visual direction for this project. Use its DESIGN.md palette, typography, spacing, component rules, and theme tokens as the source of truth for color and mood.
- Do not ask the user to pick a separate theme color, visual direction, palette, typography mood, or direction card.
- Do not emit a direction question-form, a \`direction-cards\` picker, or any visual-direction card while an active design system is present.
- If an earlier discovery answer asks to "Pick a direction for me", treat that as already satisfied by the active design system and continue with the plan.
- When a downstream framework mentions "active direction" or "theme tokens", bind those fields from the active design system instead of the built-in direction library.
`;
export interface ComposeInput {
agentId?: string | null | undefined;
includeCodexImagegenOverride?: boolean | undefined;
@ -221,6 +240,19 @@ export interface ComposeInput {
// confuses the user.
connectedExternalMcp?: ReadonlyArray<{ id: string; label?: string | undefined }>
| undefined;
// Optional `## Active plugin` / `## Plugin inputs` block. The daemon's
// plugin module renders this from an AppliedPluginSnapshot; we splice
// it in after the active skill so the plugin description sits next to
// its companion skill body in the prompt. Pass undefined when no
// plugin is bound to the run.
pluginBlock?: string | undefined;
// Plan §3.L2 / spec §23.4 — pre-rendered `## Active stage: <id>`
// blocks (one per pipeline stage active for the run). The daemon's
// pipeline runner builds these from `loadAtomBodies()` +
// `renderActiveStageBlock()` when the OD_BUNDLED_ATOM_PROMPTS env
// flag is set; otherwise this stays undefined and the prompt
// composer's hard-coded constants keep their precedence (back-compat).
activeStageBlocks?: ReadonlyArray<string> | undefined;
// Free-form instructions the user set at the global (user-level)
// settings panel. Injected after personal memory and before the
// project-level instructions.
@ -251,6 +283,8 @@ export function composeSystemPrompt({
critiqueBrand,
critiqueSkill,
connectedExternalMcp,
pluginBlock,
activeStageBlocks,
streamFormat,
userInstructions,
projectInstructions,
@ -260,6 +294,7 @@ export function composeSystemPrompt({
// checklist + critique before <artifact>) win precedence over softer
// wording later in the official base prompt.
const parts: string[] = [];
const activeDesignSystemBody = designSystemBody?.trim();
// API/BYOK mode (streamFormat === 'plain'): mirrors the same fix from
// `@open-design/contracts`'s composer. The daemon hits this path for
@ -303,9 +338,9 @@ export function composeSystemPrompt({
);
}
if (designSystemBody && designSystemBody.trim().length > 0) {
if (activeDesignSystemBody && activeDesignSystemBody.length > 0) {
parts.push(
`\n\n## Active design system${designSystemTitle ? `${designSystemTitle}` : ''}\n\nTreat the following DESIGN.md as authoritative for color, typography, spacing, and component rules. Do not invent tokens outside this palette. When you copy the active skill's seed template, bind these tokens into its \`:root\` block before generating any layout.\n\n${designSystemBody.trim()}`,
`\n\n## Active design system${designSystemTitle ? `${designSystemTitle}` : ''}\n\nTreat the following DESIGN.md as authoritative for color, typography, spacing, and component rules. Do not invent tokens outside this palette. When you copy the active skill's seed template, bind these tokens into its \`:root\` block before generating any layout.\n\n${activeDesignSystemBody}`,
);
}
@ -347,6 +382,24 @@ export function composeSystemPrompt({
);
}
if (pluginBlock && pluginBlock.trim().length > 0) {
parts.push(pluginBlock);
}
// Plan §3.L2 / spec §23.4 — splice per-stage atom blocks immediately
// after the active plugin block. Empty entries are skipped so a
// pipeline whose stages don't resolve any bundled atom bodies
// produces zero extra prompt mass. The active-skill body above
// remains the precedence carrier; these blocks add the stage-by-
// stage atom guidance that spec §23.3.2 calls out.
if (Array.isArray(activeStageBlocks) && activeStageBlocks.length > 0) {
for (const block of activeStageBlocks) {
if (typeof block === 'string' && block.trim().length > 0) {
parts.push(block);
}
}
}
const metaBlock = renderMetadataBlock(metadata, template, audioVoiceOptions, audioVoiceOptionsError);
if (metaBlock) parts.push(metaBlock);
@ -367,10 +420,24 @@ export function composeSystemPrompt({
// `derivePreflight` above, so we only fire the generic skeleton when no
// skill seed is on offer.
const isDeckProject = skillMode === 'deck' || metadata?.kind === 'deck';
const isFreeformProject = !skillMode && (!metadata || metadata.kind === 'other');
const hasSkillSeed =
!!skillBody && /assets\/template\.html/.test(skillBody);
if (isDeckProject && !hasSkillSeed) {
parts.push(`\n\n---\n\n${DECK_FRAMEWORK_DIRECTIVE}`);
} else if (isFreeformProject && !hasSkillSeed) {
// Freeform / kind=other projects skip the kind picker entirely and
// land here. If the user's brief is a deck/keynote/slides ("讲解",
// "presentation", "make a deck"), the agent used to invent its own
// scale-to-fit + slide visibility + nav script from scratch and
// shipped subtle CSS specificity bugs (per-slide layout classes
// overriding `.slide { display:none }`). Inject the same framework
// here, prefixed with a one-line conditional so the agent only
// adopts it when the brief actually is a deck — otherwise the
// directive is read as background reference and ignored.
parts.push(
`\n\n---\n\n## If this brief is a slide deck / keynote / presentation\n\nThe user did not pre-select a "Slide deck" surface, but their request may still call for one. **If — and only if — the brief reads as slides, keynote, presentation, deck, PPT, or 讲解, follow the framework below.** Otherwise ignore everything in this section and continue with the freeform output you would have written anyway.\n\n${DECK_FRAMEWORK_DIRECTIVE}`,
);
}
const isMediaSurface =
@ -409,6 +476,10 @@ export function composeSystemPrompt({
parts.push('\n\n' + renderPanelPrompt({ cfg, brand: critiqueBrand, skill: critiqueSkill }));
}
if (activeDesignSystemBody && activeDesignSystemBody.length > 0) {
parts.push(ACTIVE_DESIGN_SYSTEM_VISUAL_DIRECTION_OVERRIDE);
}
const mcpDirective = renderConnectedExternalMcpDirective(connectedExternalMcp);
if (mcpDirective) parts.push(mcpDirective);
@ -775,6 +846,25 @@ function renderMetadataBlock(
);
}
if (Array.isArray(metadata.contextPlugins) && metadata.contextPlugins.length > 0) {
lines.push('');
lines.push('### @ plugin context');
lines.push(
'The user selected these plugins as additive context via @ mentions. Treat them as requested references to combine with the brief; only the explicit active plugin block, if present, is the executable/pinned plugin snapshot.',
);
for (const plugin of metadata.contextPlugins) {
const id = typeof plugin.id === 'string' ? plugin.id : '';
const title = typeof plugin.title === 'string' && plugin.title.trim().length > 0
? plugin.title.trim()
: id;
if (!id && !title) continue;
const description = typeof plugin.description === 'string' && plugin.description.trim().length > 0
? `${plugin.description.trim()}`
: '';
lines.push(`- ${title}${id ? ` (\`${id}\`)` : ''}${description}`);
}
}
// Curated prompt template reference for image/video projects. Inlined
// verbatim (with light truncation) so the agent can borrow structure,
// mood and phrasing without a separate fetch. The user may have edited

View file

@ -0,0 +1,127 @@
import type Database from 'better-sqlite3';
import type { MarketplaceManifest, MarketplacePluginEntry } from '@open-design/contracts';
import type {
RegistryEntry,
RegistryPublishOutcome,
RegistryPublishRequest,
RegistryTrust,
RegistryYankOutcome,
} from '@open-design/registry-protocol';
import { StaticRegistryBackend, toRegistryEntry } from './static-backend.js';
type SqliteDb = Database.Database;
export interface DatabaseRegistryBackendOptions {
id: string;
trust?: RegistryTrust;
db: SqliteDb;
}
export class DatabaseRegistryBackend extends StaticRegistryBackend {
readonly db: SqliteDb;
constructor(options: DatabaseRegistryBackendOptions) {
ensureRegistryTables(options.db);
super({
id: options.id,
kind: 'db',
trust: options.trust ?? 'restricted',
manifest: manifestFromDb(options.db, options.id),
});
this.db = options.db;
}
async publish(request: RegistryPublishRequest): Promise<RegistryPublishOutcome> {
upsertRegistryEntry(this.db, this.id, request.entry);
const [vendor, name] = request.entry.name.split('/');
return {
ok: true,
dryRun: false,
changedFiles: [
`db://${this.id}/plugins/${vendor}/${name}`,
`db://${this.id}/plugins/${vendor}/${name}/versions/${request.entry.version}`,
],
warnings: [],
};
}
async yank(name: string, version: string, reason: string): Promise<RegistryYankOutcome> {
const row = this.db.prepare(`
SELECT entry_json FROM registry_entries WHERE backend_id = ? AND name = ?
`).get(this.id, name) as { entry_json: string } | undefined;
if (!row) {
return { ok: false, name, version, reason, warnings: [`${name} not found`] };
}
const entry = JSON.parse(row.entry_json) as RegistryEntry;
const versions = entry.versions ?? [{ version: entry.version, source: entry.source }];
const nextVersions = versions.map((item) => item.version === version
? { ...item, yanked: true, yankedAt: new Date().toISOString(), yankReason: reason }
: item);
upsertRegistryEntry(this.db, this.id, {
...entry,
versions: nextVersions,
...(entry.version === version
? { yanked: true, yankedAt: new Date().toISOString(), yankReason: reason }
: {}),
});
return { ok: true, name, version, reason, warnings: [] };
}
protected override getManifest(): MarketplaceManifest {
return manifestFromDb(this.db, this.id);
}
}
export function ensureRegistryTables(db: SqliteDb): void {
db.exec(`
CREATE TABLE IF NOT EXISTS registry_entries (
backend_id TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
entry_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (backend_id, name)
)
`);
}
export function upsertRegistryEntry(
db: SqliteDb,
backendId: string,
entry: RegistryEntry,
now = Date.now(),
): void {
db.prepare(`
INSERT INTO registry_entries (backend_id, name, version, entry_json, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(backend_id, name) DO UPDATE SET
version = excluded.version,
entry_json = excluded.entry_json,
updated_at = excluded.updated_at
`).run(backendId, entry.name, entry.version, JSON.stringify(entry), now);
}
function manifestFromDb(db: SqliteDb, backendId: string): MarketplaceManifest {
const rows = db.prepare(`
SELECT entry_json FROM registry_entries WHERE backend_id = ? ORDER BY name ASC
`).all(backendId) as Array<{ entry_json: string }>;
const plugins: MarketplacePluginEntry[] = [];
for (const row of rows) {
const entry = JSON.parse(row.entry_json) as RegistryEntry;
const marketplaceEntry: MarketplacePluginEntry = {
...entry,
name: entry.name,
source: entry.source,
version: entry.version,
};
if (toRegistryEntry(marketplaceEntry)) {
plugins.push(marketplaceEntry);
}
}
return {
specVersion: '1.0.0',
name: backendId,
version: '0.0.0',
plugins,
};
}

View file

@ -0,0 +1,166 @@
import type { MarketplaceManifest } from '@open-design/contracts';
import type {
RegistryPublishOutcome,
RegistryPublishRequest,
RegistryYankOutcome,
} from '@open-design/registry-protocol';
import { StaticRegistryBackend } from './static-backend.js';
export interface GithubRegistryClient {
readMarketplace(owner: string, repo: string, ref: string, path: string): Promise<MarketplaceManifest>;
createPublishPullRequest?(request: GithubPublishMutation): Promise<{ url: string }>;
}
export interface GithubPublishMutation {
owner: string;
repo: string;
baseRef: string;
branchName: string;
title: string;
body: string;
files: Array<{ path: string; content: string }>;
}
export interface GithubRegistryBackendOptions {
id: string;
owner: string;
repo: string;
ref?: string;
marketplacePath?: string;
client: GithubRegistryClient;
}
export class GithubRegistryBackend extends StaticRegistryBackend {
readonly owner: string;
readonly repo: string;
readonly ref: string;
readonly marketplacePath: string;
readonly client: GithubRegistryClient;
private constructor(options: GithubRegistryBackendOptions & { manifest: MarketplaceManifest }) {
super({
id: options.id,
kind: 'github',
trust: 'official',
manifest: options.manifest,
});
this.owner = options.owner;
this.repo = options.repo;
this.ref = options.ref ?? 'main';
this.marketplacePath = options.marketplacePath ?? 'plugins/registry/official/open-design-marketplace.json';
this.client = options.client;
}
static async create(options: GithubRegistryBackendOptions): Promise<GithubRegistryBackend> {
const ref = options.ref ?? 'main';
const marketplacePath = options.marketplacePath ?? 'plugins/registry/official/open-design-marketplace.json';
const manifest = await options.client.readMarketplace(
options.owner,
options.repo,
ref,
marketplacePath,
);
return new GithubRegistryBackend({ ...options, ref, marketplacePath, manifest });
}
async publish(request: RegistryPublishRequest): Promise<RegistryPublishOutcome> {
const [vendor, name] = request.entry.name.split('/');
const version = request.entry.version;
const root = `plugins/${vendor}/${name}`;
const files = [
{
path: `${root}/entry.json`,
content: JSON.stringify(request.entry, null, 2) + '\n',
},
{
path: `${root}/versions/${version}.json`,
content: JSON.stringify({
...request.entry,
publishedAt: new Date().toISOString(),
tag: request.tag ?? 'latest',
}, null, 2) + '\n',
},
];
if (request.dryRun || !this.client.createPublishPullRequest) {
return {
ok: true,
dryRun: true,
changedFiles: files.map((file) => file.path),
warnings: this.client.createPublishPullRequest
? []
: ['github mutation client unavailable; emitted dry-run payload only'],
};
}
const mutation: GithubPublishMutation = {
owner: this.owner,
repo: this.repo,
baseRef: this.ref,
branchName: `publish/${vendor}-${name}-${version}`,
title: `Add ${request.entry.name}@${version}`,
body: renderPublishBody(request),
files,
};
const pr = await this.client.createPublishPullRequest(mutation);
return {
ok: true,
dryRun: false,
pullRequestUrl: pr.url,
changedFiles: files.map((file) => file.path),
warnings: [],
};
}
async yank(name: string, version: string, reason: string): Promise<RegistryYankOutcome> {
const [vendor, pluginName] = name.split('/');
const path = `plugins/${vendor}/${pluginName}/versions/${version}.json`;
if (!this.client.createPublishPullRequest) {
return {
ok: true,
name,
version,
reason,
warnings: ['github mutation client unavailable; emitted dry-run yank only'],
};
}
const mutation: GithubPublishMutation = {
owner: this.owner,
repo: this.repo,
baseRef: this.ref,
branchName: `yank/${vendor}-${pluginName}-${version}`,
title: `Yank ${name}@${version}`,
body: `Yank ${name}@${version}\n\nReason: ${reason}\n`,
files: [
{
path,
content: JSON.stringify({
name,
version,
yanked: true,
yankedAt: new Date().toISOString(),
yankReason: reason,
}, null, 2) + '\n',
},
],
};
const pr = await this.client.createPublishPullRequest(mutation);
return { ok: true, name, version, reason, pullRequestUrl: pr.url, warnings: [] };
}
}
function renderPublishBody(request: RegistryPublishRequest): string {
return [
`Publish ${request.entry.name}@${request.entry.version}`,
'',
request.entry.description ?? '',
'',
'## Registry metadata',
'',
`- source: ${request.entry.source}`,
`- integrity: ${request.entry.integrity ?? request.entry.dist?.integrity ?? '(pending)'}`,
`- manifestDigest: ${request.entry.manifestDigest ?? request.entry.dist?.manifestDigest ?? '(pending)'}`,
`- capabilities: ${(request.entry.capabilitiesSummary ?? []).join(', ') || '(none declared)'}`,
request.changelog ? `\n## Changelog\n\n${request.changelog}` : '',
].filter(Boolean).join('\n');
}

View file

@ -0,0 +1,191 @@
import type { MarketplaceManifest, MarketplacePluginEntry } from '@open-design/contracts';
import type {
RegistryBackend,
RegistryDoctorReport,
RegistryEntry,
RegistrySearchQuery,
RegistrySearchResult,
RegistryTrust,
ResolvedRegistryEntry,
} from '@open-design/registry-protocol';
import {
RegistryEntrySchema,
RegistrySearchQuerySchema,
} from '@open-design/registry-protocol';
import {
parsePluginSpecifier,
resolveMarketplaceEntryVersion,
} from './versioning.js';
export interface StaticRegistryBackendOptions {
id: string;
kind?: 'github' | 'http' | 'local' | 'db';
trust: RegistryTrust;
manifest: MarketplaceManifest;
}
export class StaticRegistryBackend implements RegistryBackend {
readonly id: string;
readonly kind: 'github' | 'http' | 'local' | 'db';
readonly trust: RegistryTrust;
protected readonly manifestData: MarketplaceManifest;
constructor(options: StaticRegistryBackendOptions) {
this.id = options.id;
this.kind = options.kind ?? 'http';
this.trust = options.trust;
this.manifestData = options.manifest;
}
async list(): Promise<RegistryEntry[]> {
return (this.getManifest().plugins ?? [])
.filter((entry) => !entry.yanked)
.flatMap((entry) => {
const parsed = toRegistryEntry(entry);
return parsed ? [parsed] : [];
});
}
async search(input: RegistrySearchQuery): Promise<RegistrySearchResult[]> {
const query = RegistrySearchQuerySchema.parse(input);
const terms = query.query.toLowerCase().split(/\s+/g).filter(Boolean);
const tags = new Set((query.tags ?? []).map((tag) => tag.toLowerCase()));
const entries = await this.list();
const results: RegistrySearchResult[] = [];
for (const entry of entries) {
if (tags.size > 0) {
const entryTags = new Set((entry.tags ?? []).map((tag) => tag.toLowerCase()));
if (![...tags].every((tag) => entryTags.has(tag))) continue;
}
const haystack = [
entry.name,
entry.title ?? '',
entry.description ?? '',
...(entry.tags ?? []),
...(entry.capabilitiesSummary ?? []),
entry.publisher?.id ?? '',
entry.publisher?.github ?? '',
].join(' ').toLowerCase();
const matched = terms.filter((term) => haystack.includes(term));
if (terms.length > 0 && matched.length === 0) continue;
results.push({
entry,
score: terms.length === 0 ? 0 : matched.length / terms.length,
matched,
});
}
return results
.sort((left, right) => right.score - left.score || left.entry.name.localeCompare(right.entry.name))
.slice(0, query.limit ?? 100);
}
async resolve(name: string, range?: string): Promise<ResolvedRegistryEntry | null> {
const parsed = parsePluginSpecifier(range ? `${name}@${range}` : name);
const entry = (this.getManifest().plugins ?? [])
.find((plugin) => plugin.name.toLowerCase() === parsed.name.toLowerCase());
if (!entry) return null;
const resolvedVersion = resolveMarketplaceEntryVersion(entry, parsed.range);
if (!resolvedVersion) return null;
const registryEntry = toRegistryEntry(entry);
if (!registryEntry) return null;
return {
backendId: this.id,
backendKind: this.kind,
trust: this.trust,
entry: registryEntry,
version: {
version: resolvedVersion.version,
source: resolvedVersion.source,
ref: resolvedVersion.ref,
integrity: resolvedVersion.archiveIntegrity,
manifestDigest: resolvedVersion.manifestDigest,
deprecated: resolvedVersion.deprecated,
},
source: resolvedVersion.source,
ref: resolvedVersion.ref,
integrity: resolvedVersion.archiveIntegrity,
manifestDigest: resolvedVersion.manifestDigest,
};
}
async manifest(name: string, version: string): Promise<RegistryEntry | null> {
const resolved = await this.resolve(name, version);
return resolved?.entry ?? null;
}
async doctor(): Promise<RegistryDoctorReport> {
const issues = [];
const plugins = this.getManifest().plugins ?? [];
for (const entry of plugins) {
if (!/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(entry.name)) {
issues.push({
severity: 'error' as const,
code: 'invalid-name',
message: 'Registry plugin name must be vendor/plugin-name.',
pluginName: entry.name,
});
}
if (!entry.source && !entry.dist?.archive) {
issues.push({
severity: 'error' as const,
code: 'missing-source',
message: 'Registry entry must provide source or dist.archive.',
pluginName: entry.name,
});
}
if (!entry.license) {
issues.push({
severity: 'warning' as const,
code: 'missing-license',
message: 'Registry entry should declare a license.',
pluginName: entry.name,
});
}
if (!entry.capabilitiesSummary || entry.capabilitiesSummary.length === 0) {
issues.push({
severity: 'warning' as const,
code: 'missing-capabilities',
message: 'Registry entry should summarize plugin capabilities.',
pluginName: entry.name,
});
}
if (entry.yanked && !entry.yankReason) {
issues.push({
severity: 'error' as const,
code: 'missing-yank-reason',
message: 'Yanked entries must keep a human-readable reason.',
pluginName: entry.name,
});
}
}
return {
ok: !issues.some((issue) => issue.severity === 'error'),
backendId: this.id,
checkedAt: Date.now(),
entriesChecked: plugins.length,
issues,
};
}
protected getManifest(): MarketplaceManifest {
return this.manifestData;
}
}
export function toRegistryEntry(entry: MarketplacePluginEntry): RegistryEntry | null {
const parsed = RegistryEntrySchema.safeParse({
...entry,
publisher: normalizePublisher(entry.publisher),
});
return parsed.success ? parsed.data : null;
}
function normalizePublisher(publisher: MarketplacePluginEntry['publisher']) {
if (!publisher) return undefined;
return {
id: publisher.id,
github: publisher.github,
url: publisher.url,
};
}

View file

@ -0,0 +1,126 @@
import type { MarketplacePluginEntry } from '@open-design/contracts';
export interface ParsedPluginSpecifier {
name: string;
range?: string;
}
export interface ResolvedMarketplaceVersion {
version: string;
source: string;
ref?: string;
manifestDigest?: string;
archiveIntegrity?: string;
deprecated?: boolean | string;
}
export function parsePluginSpecifier(input: string): ParsedPluginSpecifier {
const trimmed = input.trim();
const slash = trimmed.indexOf('/');
const at = trimmed.lastIndexOf('@');
if (slash > 0 && at > slash + 1) {
const range = trimmed.slice(at + 1);
return range
? { name: trimmed.slice(0, at), range }
: { name: trimmed.slice(0, at) };
}
return { name: trimmed };
}
export function resolveMarketplaceEntryVersion(
entry: MarketplacePluginEntry,
requestedRange?: string,
): ResolvedMarketplaceVersion | null {
if (entry.yanked) return null;
const versions = entry.versions ?? [];
const range = requestedRange?.trim();
const defaultVersion =
entry.distTags?.latest ??
entry.version ??
versions.find((version) => !version.yanked)?.version;
const targetVersion = range && range !== 'latest'
? resolveRequestedVersion(versions, entry.distTags ?? {}, range)
: defaultVersion;
if (!targetVersion) return null;
const versionRecord = versions.find((version) => version.version === targetVersion);
if (versionRecord?.yanked) return null;
const source = versionRecord?.source ?? entry.source;
if (!source) return null;
const resolved: ResolvedMarketplaceVersion = {
version: targetVersion,
source,
};
const ref = versionRecord?.ref ?? entry.ref;
if (ref) resolved.ref = ref;
const manifestDigest =
versionRecord?.manifestDigest ??
versionRecord?.dist?.manifestDigest ??
entry.manifestDigest ??
entry.dist?.manifestDigest;
if (manifestDigest) resolved.manifestDigest = manifestDigest;
const archiveIntegrity =
versionRecord?.integrity ??
versionRecord?.dist?.integrity ??
entry.integrity ??
entry.dist?.integrity;
if (archiveIntegrity) resolved.archiveIntegrity = archiveIntegrity;
const deprecated = versionRecord?.deprecated ?? entry.deprecated;
if (deprecated !== undefined) resolved.deprecated = deprecated;
return resolved;
}
function resolveRequestedVersion(
versions: NonNullable<MarketplacePluginEntry['versions']>,
distTags: Record<string, string>,
range: string,
): string | null {
const tagged = distTags[range];
if (tagged) return tagged;
if (!range.startsWith('^') && !range.startsWith('~')) {
return range;
}
const base = parseSemver(range.slice(1));
if (!base) return null;
const candidates = versions
.filter((version) => !version.yanked)
.map((version) => version.version)
.filter((version) => {
const parsed = parseSemver(version);
if (!parsed) return false;
if (range.startsWith('^')) {
return parsed.major === base.major && compareSemver(parsed, base) >= 0;
}
return parsed.major === base.major &&
parsed.minor === base.minor &&
compareSemver(parsed, base) >= 0;
})
.sort((left, right) => compareSemver(parseSemver(right)!, parseSemver(left)!));
return candidates[0] ?? null;
}
interface SemverParts {
major: number;
minor: number;
patch: number;
}
function parseSemver(value: string): SemverParts | null {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value);
if (!match) return null;
return {
major: Number(match[1] ?? 0),
minor: Number(match[2] ?? 0),
patch: Number(match[3] ?? 0),
};
}
function compareSemver(left: SemverParts, right: SemverParts): number {
return left.major - right.major ||
left.minor - right.minor ||
left.patch - right.patch;
}

View file

@ -21,6 +21,17 @@ export function createChatRunService({
assistantMessageId: typeof meta.assistantMessageId === 'string' && meta.assistantMessageId ? meta.assistantMessageId : null,
clientRequestId: typeof meta.clientRequestId === 'string' && meta.clientRequestId ? meta.clientRequestId : null,
agentId: typeof meta.agentId === 'string' && meta.agentId ? meta.agentId : null,
// Plan §3.A1 / spec §11.5. The applied plugin snapshot id pins
// every prompt fragment and tool gate to a frozen view so replay
// is byte-equal across plugin upgrades. Runs are in-memory in
// v1 — the id lives on the run object plus on the
// `applied_plugin_snapshots` row (FK back via run_id).
appliedPluginSnapshotId:
typeof meta.appliedPluginSnapshotId === 'string' && meta.appliedPluginSnapshotId
? meta.appliedPluginSnapshotId
: null,
pluginId:
typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null,
status: 'queued',
createdAt: now,
updatedAt: now,
@ -62,6 +73,8 @@ export function createChatRunService({
conversationId: run.conversationId,
assistantMessageId: run.assistantMessageId,
agentId: run.agentId,
appliedPluginSnapshotId: run.appliedPluginSnapshotId ?? null,
pluginId: run.pluginId ?? null,
status: run.status,
createdAt: run.createdAt,
updatedAt: run.updatedAt,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,136 @@
// Phase 5 / spec §15.6 — minimal AWS Signature V4 signer.
//
// We sign S3 requests inline with the spec'd canonical-request /
// string-to-sign / signing-key / authorization recipe, using only
// node:crypto. No `@aws-sdk/*` dependency is pulled in; the
// substrate stays small and the OD daemon ships without an extra
// 60+ MB of SDK code on disk.
//
// References:
// https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
//
// Caller passes:
// - the HTTP method + path + query + headers + body
// - region + service ('s3') + AWS access key + secret key
// - optional sessionToken (for STS-vended creds)
// - optional 'now' override for tests so signatures stay
// deterministic.
//
// The signer mutates the supplied headers map by adding
// 'authorization', 'x-amz-date', 'x-amz-content-sha256', and (when
// supplied) 'x-amz-security-token'. The caller is responsible for
// setting 'host'.
//
// Path-style vs virtual-host-style: this signer does not care; the
// caller chooses by setting 'host' + path appropriately.
import { createHash, createHmac } from 'node:crypto';
export interface SigV4Credentials {
accessKeyId: string;
secretAccessKey: string;
sessionToken?: string;
}
export interface SignSigV4Input {
method: string;
// Canonical path (URI-encoded but with '/' preserved between
// segments). For S3 object keys, the caller must double-encode
// RFC-3986-reserved chars beyond the canonical pass.
path: string;
// Already-stringified canonical query (sorted key=value join).
// Pass '' when the request has no query.
query: string;
// Mutable lower-case header map. The signer adds the four amz
// headers and the Authorization header.
headers: Record<string, string>;
// Raw body bytes; pass an empty Buffer for no-body requests.
body: Buffer;
region: string;
service: string;
credentials: SigV4Credentials;
// ISO-8601 UTC datetime used for the signature. Defaults to now.
now?: Date;
}
export interface SignSigV4Result {
authorization: string;
amzDate: string;
contentSha256: string;
}
export function signSigV4(input: SignSigV4Input): SignSigV4Result {
const now = input.now ?? new Date();
const amzDate = formatAmzDate(now);
const dateStamp = amzDate.slice(0, 8);
const contentSha256 = sha256Hex(input.body);
// Add the required amz headers BEFORE composing the canonical
// request, since they're part of the signed-headers list.
input.headers['x-amz-date'] = amzDate;
input.headers['x-amz-content-sha256'] = contentSha256;
if (input.credentials.sessionToken) {
input.headers['x-amz-security-token'] = input.credentials.sessionToken;
}
// Canonical headers: lower-case keys + trim values; sort by key.
const headerKeys = Object.keys(input.headers).map((k) => k.toLowerCase()).sort();
const canonicalHeaders = headerKeys.map((k) => `${k}:${trimValue(input.headers[k] ?? input.headers[k.toLowerCase()] ?? '')}\n`).join('');
const signedHeaders = headerKeys.join(';');
const canonicalRequest = [
input.method.toUpperCase(),
input.path,
input.query,
canonicalHeaders,
signedHeaders,
contentSha256,
].join('\n');
const credentialScope = `${dateStamp}/${input.region}/${input.service}/aws4_request`;
const stringToSign = [
'AWS4-HMAC-SHA256',
amzDate,
credentialScope,
sha256Hex(Buffer.from(canonicalRequest, 'utf8')),
].join('\n');
// Derive the signing key.
const kDate = hmac(`AWS4${input.credentials.secretAccessKey}`, dateStamp);
const kRegion = hmac(kDate, input.region);
const kService = hmac(kRegion, input.service);
const kSigning = hmac(kService, 'aws4_request');
const signature = createHmac('sha256', kSigning).update(stringToSign).digest('hex');
const authorization = `AWS4-HMAC-SHA256 Credential=${input.credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
input.headers['authorization'] = authorization;
return { authorization, amzDate, contentSha256 };
}
function sha256Hex(body: Buffer): string {
return createHash('sha256').update(body).digest('hex');
}
function hmac(key: string | Buffer, value: string): Buffer {
return createHmac('sha256', key).update(value).digest();
}
function trimValue(v: string): string {
return v.trim().replace(/\s+/g, ' ');
}
function formatAmzDate(d: Date): string {
const iso = d.toISOString().replace(/[-:]/g, '');
// ISO is YYYYMMDDTHHmmss.sssZ; AWS expects YYYYMMDDTHHMMSSZ.
return `${iso.slice(0, 15)}Z`;
}
// URI-encode a path segment per RFC 3986, with the AWS twist that
// the slash separator between segments stays literal. S3 object key
// chars that fall outside the unreserved set get %HH-encoded.
export function encodeS3PathSegment(seg: string): string {
return encodeURIComponent(seg)
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
}

View file

@ -0,0 +1,74 @@
// Phase 5 / spec §15.6 — `DaemonDb` adapter stub.
//
// Spec §15.6 calls out a Postgres adapter so multi-replica daemons
// can share state behind a load balancer. v1 ships local SQLite via
// better-sqlite3 (already in `apps/daemon/src/db.ts`). The full lift
// is a substantial migration; this module is the substrate slice
// that pins the parameter surface so a follow-up PR can land the
// adapter without re-litigating the env-var contract.
//
// Today's resolver simply records the operator's choice; the
// existing better-sqlite3 path is the only reachable backend.
// `OD_DAEMON_DB=postgres` returns a stub that throws when used so
// a misconfigured operator sees a clear error instead of silently
// dropping writes onto a non-existent backend.
export type DaemonDbKind = 'sqlite' | 'postgres';
export interface DaemonDbConfig {
kind: DaemonDbKind;
// Resolution metadata the future Postgres adapter will read.
postgres?: {
host: string;
port: number;
database: string;
user: string;
// Password / connection string are looked up at runtime from the
// matching secret manager; we never read them through env at this
// layer.
sslMode?: 'disable' | 'require' | 'verify-full';
};
}
export class DaemonDbConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'DaemonDbConfigError';
}
}
export function resolveDaemonDbConfig(env?: Record<string, string | undefined>): DaemonDbConfig {
const e = env ?? process.env;
const kind = (e.OD_DAEMON_DB ?? 'sqlite').trim().toLowerCase();
if (kind === 'postgres') {
const host = e.OD_PG_HOST ?? '';
const portStr = e.OD_PG_PORT ?? '5432';
const database = e.OD_PG_DATABASE ?? '';
const user = e.OD_PG_USER ?? '';
const sslMode = e.OD_PG_SSL_MODE === 'disable' || e.OD_PG_SSL_MODE === 'verify-full'
? e.OD_PG_SSL_MODE
: 'require';
if (!host || !database || !user) {
throw new DaemonDbConfigError(
'OD_DAEMON_DB=postgres requires OD_PG_HOST, OD_PG_DATABASE, OD_PG_USER. ' +
'OD_PG_PORT defaults to 5432; OD_PG_SSL_MODE defaults to "require".',
);
}
return {
kind: 'postgres',
postgres: {
host,
port: Number.parseInt(portStr, 10) || 5432,
database,
user,
sslMode,
},
};
}
if (kind !== 'sqlite' && kind !== '') {
throw new DaemonDbConfigError(
`unknown OD_DAEMON_DB value '${kind}'. Accepted: 'sqlite' (default), 'postgres'.`,
);
}
return { kind: 'sqlite' };
}

View file

@ -0,0 +1,200 @@
// Phase 5 / spec §15.6 / plan §3.GG1 — daemon-DB inspection helper.
//
// Pure helper that walks a SQLite db file + schema and returns a
// structured inventory: file size, table list, per-table row count,
// schema version (the user_version PRAGMA we already use for
// migrations).
//
// Used by:
// - `od daemon db status` (CLI ops sanity check),
// - the `od doctor` aggregator (a future patch can fold the
// summary in without re-implementing the SQLite read).
//
// Pure relative to its inputs: callers pass the SQLite handle +
// the on-disk file path. The function never opens a new
// connection or mutates state.
import { promises as fsp } from 'node:fs';
import type Database from 'better-sqlite3';
type SqliteDb = Database.Database;
export interface DaemonDbTableInfo {
name: string;
rowCount: number;
}
export interface DaemonDbStatusReport {
kind: 'sqlite' | 'postgres';
// Absolute path on disk (sqlite); connection identifier
// (postgres). For the postgres stub we surface 'host:port/db'.
location: string;
// Total bytes the DB file occupies. Sums sqlite + sqlite-wal +
// sqlite-shm so the report matches `du -h` rather than just the
// primary file.
sizeBytes: number;
schemaVersion: number | null;
tables: DaemonDbTableInfo[];
generatedAt: number;
}
const SYSTEM_TABLE_PREFIXES = ['sqlite_', 'better_sqlite3_'];
function isSystemTable(name: string): boolean {
return SYSTEM_TABLE_PREFIXES.some((p) => name.startsWith(p));
}
export async function inspectSqliteDatabase(input: {
db: SqliteDb;
file: string;
}): Promise<DaemonDbStatusReport> {
const { db, file } = input;
// 1. Schema version (user_version pragma).
let schemaVersion: number | null = null;
try {
const v = db.pragma('user_version', { simple: true });
schemaVersion = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(schemaVersion)) schemaVersion = null;
} catch {
schemaVersion = null;
}
// 2. Table list with row counts.
const tables: DaemonDbTableInfo[] = [];
try {
const names = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`)
.all() as Array<{ name: string }>;
for (const { name } of names) {
if (isSystemTable(name)) continue;
try {
const safe = sanitizeTableName(name);
if (!safe) continue;
const row = db.prepare(`SELECT count(*) AS c FROM "${safe}"`).get() as { c: number } | undefined;
tables.push({ name: safe, rowCount: row?.c ?? 0 });
} catch {
// A malformed view / corrupted table shouldn't fail the
// whole report; record 0 rows.
tables.push({ name, rowCount: 0 });
}
}
} catch {
// ignore — empty tables[] surfaces 'cannot read schema' to the
// caller (CLI shows 0 tables, which is itself a useful signal).
}
// 3. File size = primary + -wal + -shm so the number matches du.
const sizeBytes = await sumFileSizes([file, `${file}-wal`, `${file}-shm`]);
return {
kind: 'sqlite',
location: file,
sizeBytes,
schemaVersion,
tables,
generatedAt: Date.now(),
};
}
async function sumFileSizes(paths: ReadonlyArray<string>): Promise<number> {
let total = 0;
for (const p of paths) {
try {
const stat = await fsp.stat(p);
total += stat.size;
} catch {
// missing -wal / -shm is normal when the DB hasn't been written
// since open.
}
}
return total;
}
function sanitizeTableName(name: string): string | null {
// Allow ASCII alphanumerics + underscore; SQLite identifier sanity
// check. Prevents accidental SQL injection if a malicious migration
// ever invents a hostile table name.
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return null;
return name;
}
// Plan §3.LL1 — daemon-DB integrity check.
//
// Wraps SQLite's PRAGMA integrity_check + foreign_key_check + the
// fast PRAGMA quick_check variant. Returns a structured report so
// the CLI can pretty-print + a CI tool can parse JSON.
//
// PRAGMA integrity_check returns either ['ok'] when the DB is
// healthy or one row per issue. PRAGMA foreign_key_check returns
// rows for each FK violation; we surface them as 'fk' issues.
export type DbIntegrityIssueKind = 'integrity' | 'foreign_key';
export interface DbIntegrityIssue {
kind: DbIntegrityIssueKind;
message: string;
}
export interface DbIntegrityReport {
ok: boolean;
// 'integrity_check' (default) or 'quick_check' — quick is faster
// but skips the index-content check.
mode: 'integrity_check' | 'quick_check';
issues: DbIntegrityIssue[];
elapsedMs: number;
generatedAt: number;
}
export interface VerifyDbOptions {
db: SqliteDb;
quick?: boolean;
}
export function verifySqliteIntegrity(opts: VerifyDbOptions): DbIntegrityReport {
const { db, quick = false } = opts;
const startedAt = Date.now();
const issues: DbIntegrityIssue[] = [];
// 1. integrity_check / quick_check.
const pragma = quick ? 'quick_check' : 'integrity_check';
try {
const rows = db.pragma(pragma) as Array<Record<string, unknown>>;
for (const row of rows) {
// SQLite returns the string under either `integrity_check`,
// `quick_check`, or just the first column. Normalise to the
// first string-valued field.
const message = (row[pragma] ?? Object.values(row)[0]) as unknown;
if (typeof message !== 'string') continue;
if (message.toLowerCase() === 'ok') continue;
issues.push({ kind: 'integrity', message });
}
} catch (err) {
issues.push({ kind: 'integrity', message: `pragma ${pragma} threw: ${(err as Error).message}` });
}
// 2. foreign_key_check.
try {
const rows = db.pragma('foreign_key_check') as Array<{ table?: string; rowid?: number; parent?: string; fkid?: number }>;
for (const row of rows) {
const tbl = row.table ?? '?';
const parent = row.parent ?? '?';
const fkid = row.fkid ?? '?';
const rowid = row.rowid ?? '?';
issues.push({
kind: 'foreign_key',
message: `FK violation in ${tbl} (rowid=${rowid}) referencing ${parent} (fkid=${fkid})`,
});
}
} catch (err) {
issues.push({ kind: 'foreign_key', message: `pragma foreign_key_check threw: ${(err as Error).message}` });
}
return {
ok: issues.length === 0,
mode: quick ? 'quick_check' : 'integrity_check',
issues,
elapsedMs: Date.now() - startedAt,
generatedAt: Date.now(),
};
}

View file

@ -0,0 +1,436 @@
// Phase 5 / spec §15.6 — `ProjectStorage` adapter interface.
//
// The daemon's project filesystem usage today is concentrated in
// `apps/daemon/src/projects.ts` (read/write/list/delete). Spec §15.6
// folds those calls behind a narrow interface so a future Phase 5
// patch can swap the implementation between local-disk (v1 default)
// and S3-compatible blob stores (AWS S3, GCS S3-compat, Azure Blob
// shim, Aliyun OSS, Tencent COS, Huawei OBS) without rewriting
// callers.
//
// This module is the substrate slice. It ships:
//
// - `ProjectStorage` interface — the narrow contract every backend
// implements (read / write / list / delete / stat).
// - `LocalProjectStorage` — a thin wrapper over the existing
// `apps/daemon/src/projects.ts` helpers; this is the v1 default.
// - `S3ProjectStorage` — a stub that mirrors the interface and
// records the operations it would perform. The real AWS SDK
// wiring is the next Phase 5 PR; the stub exists so unit tests
// can lock the interface contract.
//
// The daemon's existing project routes don't yet route through this
// adapter — that's an opt-in flag away (`OD_PROJECT_STORAGE=s3`).
// The substrate slice keeps the call sites unchanged so a wrong
// adapter never silently corrupts user data on roll-out.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { encodeS3PathSegment, signSigV4, type SigV4Credentials } from './aws-sigv4.js';
export interface ProjectFileMeta {
// Path relative to the project root. Always uses forward slashes.
path: string;
// Total size in bytes.
size: number;
// Unix epoch milliseconds of last modification.
mtimeMs: number;
}
export interface ProjectStorage {
// Reads `<projectId>/<relpath>` into a Buffer. Throws ENOENT-style
// errors when missing; the caller maps to HTTP 404.
readFile(projectId: string, relpath: string): Promise<Buffer>;
// Writes `<projectId>/<relpath>` atomically. The default
// implementation creates parent directories as needed.
writeFile(projectId: string, relpath: string, body: Buffer): Promise<ProjectFileMeta>;
// Lists every file under `<projectId>/` recursively. The order is
// implementation-defined; callers that need deterministic order
// sort by `path`.
listFiles(projectId: string): Promise<ProjectFileMeta[]>;
// Deletes a single file under `<projectId>/`. Idempotent — missing
// files do not throw.
deleteFile(projectId: string, relpath: string): Promise<void>;
// Reports metadata for a single file without reading its bytes.
// Returns null when the file is missing.
statFile(projectId: string, relpath: string): Promise<ProjectFileMeta | null>;
}
export class StorageError extends Error {
readonly code: 'NOT_FOUND' | 'TRAVERSAL' | 'IO';
constructor(code: 'NOT_FOUND' | 'TRAVERSAL' | 'IO', message: string) {
super(message);
this.code = code;
this.name = 'StorageError';
}
}
/**
* v1 default backed by the daemon's existing `<dataDir>/.od/projects/`
* filesystem layout. Pure pass-through to fs/promises with the
* traversal guard the legacy `projects.ts` helpers already enforce.
*/
export class LocalProjectStorage implements ProjectStorage {
constructor(private readonly projectsRoot: string) {}
async readFile(projectId: string, relpath: string): Promise<Buffer> {
const abs = this.resolvePath(projectId, relpath);
try {
return await fsp.readFile(abs);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT') throw new StorageError('NOT_FOUND', `${projectId}/${relpath} not found`);
throw new StorageError('IO', `read failed: ${e.message ?? String(e)}`);
}
}
async writeFile(projectId: string, relpath: string, body: Buffer): Promise<ProjectFileMeta> {
const abs = this.resolvePath(projectId, relpath);
await fsp.mkdir(path.dirname(abs), { recursive: true });
await fsp.writeFile(abs, body);
const stat = await fsp.stat(abs);
return {
path: normalizeRel(relpath),
size: stat.size,
mtimeMs: stat.mtimeMs,
};
}
async listFiles(projectId: string): Promise<ProjectFileMeta[]> {
const root = path.join(this.projectsRoot, projectId);
const out: ProjectFileMeta[] = [];
const queue: string[] = [root];
while (queue.length > 0) {
const dir = queue.pop()!;
let entries;
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw new StorageError('IO', `list failed: ${(err as Error).message}`);
}
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
queue.push(abs);
continue;
}
if (!entry.isFile()) continue;
const stat = await fsp.stat(abs);
const rel = path.relative(root, abs).split(path.sep).join('/');
out.push({ path: rel, size: stat.size, mtimeMs: stat.mtimeMs });
}
}
return out;
}
async deleteFile(projectId: string, relpath: string): Promise<void> {
const abs = this.resolvePath(projectId, relpath);
try {
await fsp.rm(abs, { force: true });
} catch (err) {
throw new StorageError('IO', `delete failed: ${(err as Error).message}`);
}
}
async statFile(projectId: string, relpath: string): Promise<ProjectFileMeta | null> {
const abs = this.resolvePath(projectId, relpath);
try {
const stat = await fsp.stat(abs);
if (!stat.isFile()) return null;
return {
path: normalizeRel(relpath),
size: stat.size,
mtimeMs: stat.mtimeMs,
};
} catch {
return null;
}
}
private resolvePath(projectId: string, relpath: string): string {
if (!projectId || projectId.includes('/') || projectId.includes('\\') || projectId.includes('\0') || projectId.includes('..')) {
throw new StorageError('TRAVERSAL', `invalid projectId ${projectId}`);
}
const normalized = normalizeRel(relpath);
if (!normalized) throw new StorageError('TRAVERSAL', 'empty relpath');
if (normalized.split('/').some((seg) => seg === '..' || seg === '.')) {
throw new StorageError('TRAVERSAL', `unsafe relpath ${relpath}`);
}
return path.join(this.projectsRoot, projectId, ...normalized.split('/'));
}
}
/**
* Phase 5 / spec §15.6 / plan §3.U1 S3-compatible blob backend.
*
* Signs requests inline with AWS SigV4 (see ./aws-sigv4.ts) using
* only node:crypto. No `@aws-sdk/*` dep is pulled in; the backend
* targets AWS S3 + every S3-compatible store the spec lists (Aliyun
* OSS, Tencent COS, Huawei OBS, MinIO).
*
* Five operations:
* readFile \u2192 GET /<key>
* writeFile \u2192 PUT /<key> (with x-amz-content-sha256)
* deleteFile \u2192 DELETE /<key>
* statFile \u2192 HEAD /<key>
* listFiles \u2192 GET /?list-type=2&prefix=<projectId>/...
*
* Network is pluggable: pass `fetchFn` in the constructor (tests
* inject a stub; production defaults to globalThis.fetch).
*/
export interface S3ProjectStorageOptions {
bucket: string;
region: string;
// Optional path prefix inside the bucket. Lets multiple OD
// deployments share one bucket.
prefix?: string;
// S3-compatible endpoint URL (Aliyun OSS, Tencent COS, Huawei OBS,
// MinIO). Omit for AWS S3.
endpoint?: string;
// AWS access credentials. Read from OD_S3_ACCESS_KEY_ID /
// OD_S3_SECRET_ACCESS_KEY by resolveProjectStorage(); the test
// harness can pass them directly.
credentials: SigV4Credentials;
// Pluggable fetch for tests. Defaults to globalThis.fetch.
fetchFn?: typeof fetch;
// Override for clock — tests pin signatures with this. Production
// leaves it undefined (signSigV4 falls back to `new Date()`).
now?: () => Date;
}
export class S3ProjectStorage implements ProjectStorage {
private readonly fetchFn: typeof fetch;
constructor(public readonly options: S3ProjectStorageOptions) {
if (!options.bucket) throw new StorageError('IO', 'S3ProjectStorage requires a bucket');
if (!options.region) throw new StorageError('IO', 'S3ProjectStorage requires a region');
if (!options.credentials?.accessKeyId) throw new StorageError('IO', 'S3ProjectStorage requires credentials.accessKeyId');
if (!options.credentials?.secretAccessKey) throw new StorageError('IO', 'S3ProjectStorage requires credentials.secretAccessKey');
const fn = options.fetchFn ?? globalThis.fetch;
if (!fn) throw new StorageError('IO', 'S3ProjectStorage requires a fetch implementation');
this.fetchFn = fn;
}
async readFile(projectId: string, relpath: string): Promise<Buffer> {
const key = this.keyFor(projectId, relpath);
const res = await this.signedRequest({ method: 'GET', key });
if (res.status === 404) throw new StorageError('NOT_FOUND', `${projectId}/${relpath} not found`);
if (!res.ok) throw new StorageError('IO', `S3 GET ${key} \u2192 ${res.status} ${res.statusText}: ${await safeText(res)}`);
return Buffer.from(await res.arrayBuffer());
}
async writeFile(projectId: string, relpath: string, body: Buffer): Promise<ProjectFileMeta> {
const key = this.keyFor(projectId, relpath);
const res = await this.signedRequest({ method: 'PUT', key, body });
if (!res.ok) throw new StorageError('IO', `S3 PUT ${key} \u2192 ${res.status} ${res.statusText}: ${await safeText(res)}`);
return {
path: normalizeRel(relpath),
size: body.byteLength,
mtimeMs: Date.now(),
};
}
async deleteFile(projectId: string, relpath: string): Promise<void> {
const key = this.keyFor(projectId, relpath);
const res = await this.signedRequest({ method: 'DELETE', key });
// S3 returns 204 on successful delete; idempotent if missing.
if (!res.ok && res.status !== 404) {
throw new StorageError('IO', `S3 DELETE ${key} \u2192 ${res.status} ${res.statusText}: ${await safeText(res)}`);
}
}
async statFile(projectId: string, relpath: string): Promise<ProjectFileMeta | null> {
const key = this.keyFor(projectId, relpath);
const res = await this.signedRequest({ method: 'HEAD', key });
if (res.status === 404) return null;
if (!res.ok) throw new StorageError('IO', `S3 HEAD ${key} \u2192 ${res.status} ${res.statusText}`);
const contentLength = Number(res.headers.get('content-length') ?? '0');
const lastModified = res.headers.get('last-modified');
return {
path: normalizeRel(relpath),
size: Number.isFinite(contentLength) ? contentLength : 0,
mtimeMs: lastModified ? Date.parse(lastModified) : Date.now(),
};
}
async listFiles(projectId: string): Promise<ProjectFileMeta[]> {
const projectPrefix = this.keyFor(projectId, '');
const out: ProjectFileMeta[] = [];
let continuationToken: string | undefined;
// Cap iterations so a hostile bucket can't loop forever.
for (let pages = 0; pages < 1000; pages++) {
const params: Array<[string, string]> = [
['list-type', '2'],
['prefix', projectPrefix],
];
if (continuationToken) params.push(['continuation-token', continuationToken]);
params.sort((a, b) => a[0].localeCompare(b[0]));
const query = params.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
const res = await this.signedRequest({ method: 'GET', key: '', extraQuery: query });
if (!res.ok) {
throw new StorageError('IO', `S3 LIST ${projectPrefix} \u2192 ${res.status} ${res.statusText}: ${await safeText(res)}`);
}
const xml = await res.text();
const { entries, isTruncated, nextToken } = parseListBucketV2Xml(xml);
for (const e of entries) {
// Strip the per-bucket projectPrefix to surface project-relative paths.
const relStart = projectPrefix ? projectPrefix.length + (projectPrefix.endsWith('/') ? 0 : 1) : 0;
const rel = e.key.slice(relStart).replace(/^\/+/, '');
if (!rel) continue; // skip the prefix marker itself
out.push({ path: rel, size: e.size, mtimeMs: e.lastModifiedMs });
}
if (!isTruncated || !nextToken) break;
continuationToken = nextToken;
}
return out;
}
// Build the canonical S3 key the impl uses. Exposed for tests so
// the prefix / projectId / relpath join is stable.
keyFor(projectId: string, relpath: string): string {
if (!projectId || projectId.includes('/') || projectId.includes('\\') || projectId.includes('..')) {
throw new StorageError('TRAVERSAL', `invalid projectId ${projectId}`);
}
const normalized = relpath ? normalizeRel(relpath) : '';
if (normalized.split('/').some((seg) => seg === '..' || seg === '.')) {
throw new StorageError('TRAVERSAL', `unsafe relpath ${relpath}`);
}
const segments = [this.options.prefix?.replace(/^\/+|\/+$/g, ''), projectId, normalized]
.filter((s): s is string => typeof s === 'string' && s.length > 0);
return segments.join('/');
}
private endpointBase(): string {
if (this.options.endpoint) return this.options.endpoint.replace(/\/+$/, '');
return `https://${this.options.bucket}.s3.${this.options.region}.amazonaws.com`;
}
private async signedRequest(args: {
method: string;
key: string;
body?: Buffer;
extraQuery?: string;
}): Promise<Response> {
const base = this.endpointBase();
const baseHost = new URL(base).host;
// Path-style for endpoint overrides (typical for S3-compat
// services + MinIO test setups); virtual-host-style when
// endpoint is omitted (default AWS S3).
let pathSegment: string;
let host: string;
if (this.options.endpoint) {
const segments = [this.options.bucket, ...args.key.split('/').filter(Boolean).map(encodeS3PathSegment)];
pathSegment = '/' + segments.join('/');
host = baseHost;
} else {
const segments = args.key.split('/').filter(Boolean).map(encodeS3PathSegment);
pathSegment = segments.length === 0 ? '/' : '/' + segments.join('/');
host = baseHost;
}
const headers: Record<string, string> = {
'host': host,
};
const body = args.body ?? Buffer.alloc(0);
const now = this.options.now ? this.options.now() : new Date();
signSigV4({
method: args.method,
path: pathSegment,
query: args.extraQuery ?? '',
headers,
body,
region: this.options.region,
service: 's3',
credentials: this.options.credentials,
now,
});
const url = `${base.replace(/\/+$/, '')}${pathSegment}${args.extraQuery ? `?${args.extraQuery}` : ''}`;
const init: RequestInit = {
method: args.method,
headers,
...(args.body ? { body: args.body } : {}),
};
return this.fetchFn(url, init);
}
}
interface ListBucketEntry { key: string; size: number; lastModifiedMs: number }
function parseListBucketV2Xml(xml: string): { entries: ListBucketEntry[]; isTruncated: boolean; nextToken?: string } {
const entries: ListBucketEntry[] = [];
// Lightweight XML scrape — we accept S3 / S3-compat shapes:
// <Contents>
// <Key>...</Key>
// <LastModified>2026-...</LastModified>
// <Size>1234</Size>
// </Contents>
// and a single <NextContinuationToken>...</NextContinuationToken>
// and <IsTruncated>true|false</IsTruncated>.
const contentsRe = /<Contents\b[^>]*>([\s\S]*?)<\/Contents>/g;
let m: RegExpExecArray | null;
while ((m = contentsRe.exec(xml)) !== null) {
const block = m[1] ?? '';
const key = pluckTag(block, 'Key');
const size = Number(pluckTag(block, 'Size') ?? '0');
const lastModifiedRaw = pluckTag(block, 'LastModified');
if (!key) continue;
entries.push({
key,
size: Number.isFinite(size) ? size : 0,
lastModifiedMs: lastModifiedRaw ? Date.parse(lastModifiedRaw) : Date.now(),
});
}
const isTruncated = (pluckTag(xml, 'IsTruncated') ?? 'false').toLowerCase() === 'true';
const nextToken = pluckTag(xml, 'NextContinuationToken') ?? undefined;
return nextToken ? { entries, isTruncated, nextToken } : { entries, isTruncated };
}
function pluckTag(text: string, tag: string): string | undefined {
const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`);
const m = re.exec(text);
return m ? m[1] : undefined;
}
async function safeText(res: Response): Promise<string> {
try { return (await res.text()).slice(0, 256); } catch { return ''; }
}
/**
* Resolve the daemon-wide project storage adapter from environment.
* Default is local-disk; setting OD_PROJECT_STORAGE=s3 pulls the
* stub above (and will pull the real impl once it lands).
*/
export function resolveProjectStorage(opts: {
projectsRoot: string;
env?: Record<string, string | undefined>;
}): ProjectStorage {
const env = opts.env ?? process.env;
const kind = (env.OD_PROJECT_STORAGE ?? 'local').trim().toLowerCase();
if (kind === 's3') {
// Read AWS creds from the OD-specific knobs first, then fall
// back to the standard AWS_* env vars so existing AWS toolchain
// setups (`aws configure` exporters, IAM-role pods) drop in
// without renaming.
const accessKeyId = env.OD_S3_ACCESS_KEY_ID ?? env.AWS_ACCESS_KEY_ID ?? '';
const secretAccessKey = env.OD_S3_SECRET_ACCESS_KEY ?? env.AWS_SECRET_ACCESS_KEY ?? '';
const sessionToken = env.OD_S3_SESSION_TOKEN ?? env.AWS_SESSION_TOKEN;
const credentials: SigV4Credentials = { accessKeyId, secretAccessKey };
if (sessionToken) credentials.sessionToken = sessionToken;
return new S3ProjectStorage({
bucket: env.OD_S3_BUCKET ?? '',
region: env.OD_S3_REGION ?? env.AWS_REGION ?? '',
...(env.OD_S3_PREFIX ? { prefix: env.OD_S3_PREFIX } : {}),
...(env.OD_S3_ENDPOINT ? { endpoint: env.OD_S3_ENDPOINT } : {}),
credentials,
});
}
return new LocalProjectStorage(opts.projectsRoot);
}
function normalizeRel(relpath: string): string {
return String(relpath || '')
.replace(/^[\\/]+/, '')
.replace(/[\\]+/g, '/')
.replace(/\/+/g, '/');
}

View file

@ -38,6 +38,15 @@ export interface ToolTokenGrant {
allowedOperations: readonly ToolOperation[];
issuedAt: string;
expiresAt: string;
// Plan §3.A3 / spec §9. When the run is plugin-driven, the snapshot id
// and the cached trust tier ride alongside the token so
// `/api/tools/connectors/execute` can re-validate per-call without
// re-reading the SQLite row. `pluginSnapshotId` undefined means the
// run is not plugin-driven; the connector gate is bypassed (legacy
// behavior).
pluginSnapshotId?: string;
pluginTrust?: 'trusted' | 'restricted' | 'bundled';
pluginCapabilitiesGranted?: readonly string[];
}
export interface MintToolTokenOptions {
@ -47,6 +56,9 @@ export interface MintToolTokenOptions {
allowedOperations?: readonly ToolOperation[];
ttlMs?: number;
nowMs?: number;
pluginSnapshotId?: string;
pluginTrust?: 'trusted' | 'restricted' | 'bundled';
pluginCapabilitiesGranted?: readonly string[];
}
export type ToolTokenValidationResult =
@ -72,6 +84,35 @@ function asPublicGrant(stored: StoredToolTokenGrant): ToolTokenGrant {
return grant;
}
// Plan §3.A3 / spec §9: pure connector capability gate over a token grant.
// When the grant carries no plugin snapshot id, the call is from a
// non-plugin run and we let it through (back-compat). When the grant
// carries a snapshot id, we apply the §9 / §5.3 rule:
//
// - trusted / bundled plugins implicitly carry connector:* — accept.
// - restricted plugins must list `connector:<id>` in
// pluginCapabilitiesGranted — otherwise reject with TOOL_OPERATION_DENIED.
//
// Used by `/api/tools/connectors/execute` (defense in depth, c). Phase 4
// extends this to `connector:*` glob support if the spec patches §5.3 to
// allow it; today we accept only the exact id form.
export function checkConnectorAccess(
grant: ToolTokenGrant,
connectorId: string,
): { ok: true } | { ok: false; reason: string } {
if (!grant.pluginSnapshotId) return { ok: true };
const tier = grant.pluginTrust ?? 'restricted';
if (tier !== 'restricted') return { ok: true };
const granted = new Set(grant.pluginCapabilitiesGranted ?? []);
if (granted.has(`connector:${connectorId}`) || granted.has('connector')) {
return { ok: true };
}
return {
ok: false,
reason: `restricted plugin (snapshot ${grant.pluginSnapshotId}) lacks "connector:${connectorId}" — grant via /api/plugins/:id/trust before retrying`,
};
}
export class ToolTokenRegistry {
readonly #byTokenHash = new Map<string, StoredToolTokenGrant>();
readonly #tokenHashesByRunId = new Map<string, Set<string>>();
@ -102,6 +143,11 @@ export class ToolTokenRegistry {
expiresAt: new Date(expiresAtMs).toISOString(),
expiresAtMs,
timer,
...(options.pluginSnapshotId ? { pluginSnapshotId: options.pluginSnapshotId } : {}),
...(options.pluginTrust ? { pluginTrust: options.pluginTrust } : {}),
...(options.pluginCapabilitiesGranted
? { pluginCapabilitiesGranted: [...options.pluginCapabilitiesGranted] }
: {}),
};
this.#byTokenHash.set(hash, stored);

View file

@ -0,0 +1,37 @@
// Phase 4 / spec §10.3.5 — GET /api/runs/:id/agui smoke.
//
// Covers the basic shape of the AG-UI canonical event stream:
// - 404 for an unknown run id.
// - Replays existing events through the encoder when a client
// reconnects with a Last-Event-ID.
import type http from 'node:http';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
let server: http.Server;
let baseUrl: string;
let shutdown: (() => Promise<void> | void) | undefined;
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
url: string;
server: http.Server;
shutdown?: () => Promise<void> | void;
};
baseUrl = started.url;
server = started.server;
shutdown = started.shutdown;
});
afterAll(async () => {
await Promise.resolve(shutdown?.());
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe('GET /api/runs/:id/agui', () => {
it('returns 404 for an unknown run', async () => {
const resp = await fetch(`${baseUrl}/api/runs/no-such-run/agui`);
expect(resp.status).toBe(404);
});
});

View file

@ -0,0 +1,86 @@
// Plan §3.K1 / spec §15.7 — bound-API-token guard.
//
// Two halves:
// 1. The daemon refuses to start with OD_BIND_HOST=0.0.0.0 when no
// OD_API_TOKEN is set.
// 2. When OD_API_TOKEN is set, every /api/* request from a non-loopback
// peer must carry `Authorization: Bearer <OD_API_TOKEN>`. The
// health/version/status probes stay open for monitoring.
//
// Tests force the bearer-required code path by stamping the env vars
// before startServer. The daemon listens on 127.0.0.1 throughout (so
// the "refuse 0.0.0.0 without token" path is exercised by a separate
// negative case that constructs the start call directly).
import type http from 'node:http';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
const PREVIOUS_TOKEN = process.env.OD_API_TOKEN;
const PREVIOUS_HOST = process.env.OD_BIND_HOST;
let server: http.Server | undefined;
let baseUrl = '';
let shutdown: (() => Promise<void> | void) | undefined;
afterEach(async () => {
if (shutdown) await Promise.resolve(shutdown());
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
server = undefined;
shutdown = undefined;
if (PREVIOUS_TOKEN === undefined) delete process.env.OD_API_TOKEN;
else process.env.OD_API_TOKEN = PREVIOUS_TOKEN;
if (PREVIOUS_HOST === undefined) delete process.env.OD_BIND_HOST;
else process.env.OD_BIND_HOST = PREVIOUS_HOST;
});
describe('bound-API-token guard', () => {
it('refuses to start with OD_BIND_HOST=0.0.0.0 when OD_API_TOKEN is unset', async () => {
delete process.env.OD_API_TOKEN;
await expect(startServer({ port: 0, host: '0.0.0.0', returnServer: true }))
.rejects.toThrow(/OD_API_TOKEN/);
});
it('starts on a public host when OD_API_TOKEN is set', async () => {
process.env.OD_API_TOKEN = 'test-token-abc';
// Bind to 127.0.0.1 (loopback) but pretend we crossed the guard
// by setting the env var; the assertion is that startup succeeds.
const started = (await startServer({ port: 0, host: '127.0.0.1', returnServer: true })) as {
url: string;
server: http.Server;
shutdown?: () => Promise<void> | void;
};
server = started.server;
shutdown = started.shutdown;
baseUrl = started.url;
expect(baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:/);
});
});
describe('bearer middleware', () => {
beforeEach(async () => {
process.env.OD_API_TOKEN = 'secret-test-token';
const started = (await startServer({ port: 0, host: '127.0.0.1', returnServer: true })) as {
url: string;
server: http.Server;
shutdown?: () => Promise<void> | void;
};
baseUrl = started.url;
server = started.server;
shutdown = started.shutdown;
});
it('accepts loopback callers without a bearer (desktop UI flow)', async () => {
// The HTTP test client is on the same machine → req.socket.remoteAddress
// is 127.0.0.1 → middleware short-circuits.
const resp = await fetch(`${baseUrl}/api/plugins`);
expect(resp.status).toBe(200);
});
it('keeps health / version / daemon-status open without a bearer', async () => {
for (const path of ['/api/health', '/api/version', '/api/daemon/status']) {
const resp = await fetch(`${baseUrl}${path}`);
expect(resp.status).toBe(200);
}
});
});

View file

@ -0,0 +1,88 @@
// Phase 5 / spec §15.6 / plan §3.U1 — SigV4 signer correctness.
import { describe, expect, it } from 'vitest';
import { encodeS3PathSegment, signSigV4 } from '../src/storage/aws-sigv4.js';
describe('signSigV4', () => {
it('produces the AWS-documented signature for the GET-object reference request', () => {
// Reference vector adapted from
// https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html
// (GetObject, with header-based auth, range header)
const headers: Record<string, string> = {
'host': 'examplebucket.s3.amazonaws.com',
'range': 'bytes=0-9',
};
const result = signSigV4({
method: 'GET',
path: '/test.txt',
query: '',
headers,
body: Buffer.alloc(0),
region: 'us-east-1',
service: 's3',
credentials: {
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
},
now: new Date('2013-05-24T00:00:00Z'),
});
// The published reference signature.
expect(result.authorization).toBe(
'AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, ' +
'SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, ' +
'Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41',
);
expect(result.amzDate).toBe('20130524T000000Z');
expect(result.contentSha256).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
});
it('signs an empty-key listing request with sorted canonical query', () => {
const headers: Record<string, string> = { 'host': 'od-bucket.s3.us-east-1.amazonaws.com' };
const result = signSigV4({
method: 'GET',
path: '/',
query: 'list-type=2&prefix=p1%2F',
headers,
body: Buffer.alloc(0),
region: 'us-east-1',
service: 's3',
credentials: { accessKeyId: 'AKIA-FIXTURE', secretAccessKey: 'shhh' },
now: new Date('2026-05-09T12:00:00.000Z'),
});
// The authorization header must reference the four signed headers
// we expect for a no-body GET request with credentials.sessionToken
// absent.
expect(result.authorization).toContain('SignedHeaders=host;x-amz-content-sha256;x-amz-date');
expect(result.authorization).toMatch(/Signature=[0-9a-f]{64}$/);
});
it('forwards the session token into a signed x-amz-security-token header', () => {
const headers: Record<string, string> = { 'host': 'b.s3.us-east-1.amazonaws.com' };
signSigV4({
method: 'PUT',
path: '/k',
query: '',
headers,
body: Buffer.from('hi'),
region: 'us-east-1',
service: 's3',
credentials: {
accessKeyId: 'AKIA-X',
secretAccessKey: 'sk',
sessionToken: 'TOK',
},
now: new Date('2026-05-09T00:00:00Z'),
});
expect(headers['x-amz-security-token']).toBe('TOK');
expect(headers['authorization']).toMatch(/SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token/);
});
});
describe('encodeS3PathSegment', () => {
it('encodes RFC-3986-reserved chars while preserving unreserved ones', () => {
expect(encodeS3PathSegment('hello.txt')).toBe('hello.txt');
expect(encodeS3PathSegment('a/b')).toBe('a%2Fb');
expect(encodeS3PathSegment("foo'bar")).toBe('foo%27bar');
expect(encodeS3PathSegment('a b c')).toBe('a%20b%20c');
});
});

View file

@ -480,7 +480,7 @@ setInterval(() => {}, 1000);
it('keeps Claude stream runs alive while structured output is still flowing', async () => {
const previous = process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = '900';
process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = '1800';
try {
await withFakeAgent(
'claude',
@ -501,7 +501,7 @@ const timer = setInterval(() => {
return;
}
console.log(lines[index++]);
}, 200);
}, 400);
`,
async () => {
const createResponse = await fetch(`${baseUrl}/api/runs`, {

View file

@ -0,0 +1,56 @@
// Plan §3.H2 — `/api/craft` and `/api/craft/:id` route shape.
//
// `od craft list` and `od craft show <id>` are thin HTTP wrappers
// around these endpoints. We pin the JSON shape so the CLI doesn't
// drift from the daemon contract.
import type http from 'node:http';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
let server: http.Server;
let baseUrl: string;
let shutdown: (() => Promise<void> | void) | undefined;
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
url: string;
server: http.Server;
shutdown?: () => Promise<void> | void;
};
baseUrl = started.url;
server = started.server;
shutdown = started.shutdown;
});
afterAll(async () => {
await Promise.resolve(shutdown?.());
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe('GET /api/craft', () => {
it('returns a craft array of { id, label, bytes }', async () => {
const resp = await fetch(`${baseUrl}/api/craft`);
expect(resp.status).toBe(200);
const body = (await resp.json()) as { craft?: Array<{ id: string; label: string; bytes: number }> };
expect(Array.isArray(body.craft)).toBe(true);
if (body.craft && body.craft.length > 0) {
const first = body.craft[0]!;
expect(typeof first.id).toBe('string');
expect(typeof first.label).toBe('string');
expect(typeof first.bytes).toBe('number');
}
});
});
describe('GET /api/craft/:id', () => {
it('rejects malformed slugs with 400', async () => {
const resp = await fetch(`${baseUrl}/api/craft/Bad%20ID`);
expect([400, 404]).toContain(resp.status);
});
it('returns 404 for an unknown slug', async () => {
const resp = await fetch(`${baseUrl}/api/craft/no-such-craft-section-9z`);
expect(resp.status).toBe(404);
});
});

View file

@ -0,0 +1,68 @@
// Plan §3.F2 / spec §11.7 — daemon lifecycle endpoints.
//
// `od daemon status` and `od daemon stop` (both Phase 1.5) talk to the
// new /api/daemon/status + /api/daemon/shutdown routes. This suite spins
// the real Express app via `startServer` and asserts the public shape so
// the CLI can rely on it without re-checking each release.
import type http from 'node:http';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
let server: http.Server;
let baseUrl: string;
let shutdown: (() => Promise<void> | void) | undefined;
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
url: string;
server: http.Server;
shutdown?: () => Promise<void> | void;
};
baseUrl = started.url;
server = started.server;
shutdown = started.shutdown;
});
afterAll(async () => {
await Promise.resolve(shutdown?.());
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe('GET /api/daemon/status', () => {
it('returns a runtime snapshot covering version + bindHost + port + pid + installedPlugins', async () => {
const resp = await fetch(`${baseUrl}/api/daemon/status`);
expect(resp.status).toBe(200);
const body = (await resp.json()) as {
ok: boolean;
version: unknown;
bindHost: unknown;
port: unknown;
pid: unknown;
installedPlugins: unknown;
shuttingDown: boolean;
namespace?: unknown;
};
expect(body.ok).toBe(true);
expect(typeof body.version === 'string' || typeof body.version === 'object').toBe(true);
expect(typeof body.bindHost).toBe('string');
expect(typeof body.port).toBe('number');
expect(typeof body.pid).toBe('number');
expect(typeof body.installedPlugins).toBe('number');
expect(body.shuttingDown).toBe(false);
expect(body).not.toHaveProperty('namespace');
});
});
describe('POST /api/daemon/shutdown', () => {
it('only accepts requests from local-daemon-allowed origins', async () => {
// Without the local-daemon header, the route is rejected. The
// exact rejection mode is owned by `requireLocalDaemonRequest`;
// we just check that a default test client gets a non-2xx.
const resp = await fetch(`${baseUrl}/api/daemon/shutdown`, {
method: 'POST',
headers: { origin: 'https://example.com' },
});
expect([401, 403, 404]).toContain(resp.status);
});
});

View file

@ -1,22 +1,16 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, test } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createJsonIpcServer, type JsonIpcServerHandle } from "@open-design/sidecar";
import { SIDECAR_ENV, SIDECAR_MESSAGES } from "@open-design/sidecar-proto";
import { resolveMcpDaemonUrl, MCP_DEFAULT_DAEMON_URL } from "../src/mcp-daemon-url.js";
// On Windows the sidecar IPC contract switches to named pipes whose
// names are not relocatable via OD_SIDECAR_IPC_BASE, so the discovery
// case cannot use a per-test temp socket; skip just that case there.
const ipcTest = process.platform === "win32" ? test.skip : test;
import { resolveDaemonUrl, DEFAULT_DAEMON_URL } from "../src/daemon-url.js";
// Verifies the resolution chain: --daemon-url > OD_DAEMON_URL > sidecar
// IPC status discovery > legacy default. Each layer must short-circuit
// the next so the spawned `od mcp` follows the live daemon across
// restarts without re-pasting the install snippet.
// IPC status discovery > legacy default. Each layer must short-circuit the next
// so `od` clients follow the live daemon across ephemeral-port restarts.
describe("resolveMcpDaemonUrl", () => {
describe("resolveDaemonUrl", () => {
let ipcBaseDir: string;
beforeAll(() => {
@ -28,44 +22,40 @@ describe("resolveMcpDaemonUrl", () => {
});
it("prefers the explicit --daemon-url flag", async () => {
const url = await resolveMcpDaemonUrl({
const url = await resolveDaemonUrl({
flagUrl: "http://flag.example:1111",
env: {
OD_DAEMON_URL: "http://env.example:2222",
[SIDECAR_ENV.IPC_BASE]: ipcBaseDir,
[SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "daemon.sock"),
},
});
expect(url).toBe("http://flag.example:1111");
});
it("falls back to OD_DAEMON_URL when no flag given", async () => {
const url = await resolveMcpDaemonUrl({
const url = await resolveDaemonUrl({
env: {
OD_DAEMON_URL: "http://env.example:2222",
[SIDECAR_ENV.IPC_BASE]: ipcBaseDir,
[SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "daemon.sock"),
},
});
expect(url).toBe("http://env.example:2222");
});
it("returns the legacy default when no flag/env/socket is available", async () => {
const url = await resolveMcpDaemonUrl({
const url = await resolveDaemonUrl({
env: {
// Point IPC discovery at a directory with no socket; discovery
// should fail silently and we fall back to the default.
[SIDECAR_ENV.IPC_BASE]: ipcBaseDir,
[SIDECAR_ENV.NAMESPACE]: "missing-ns",
[SIDECAR_ENV.IPC_PATH]: path.join(ipcBaseDir, "missing.sock"),
},
timeoutMs: 200,
});
expect(url).toBe(MCP_DEFAULT_DAEMON_URL);
expect(url).toBe(DEFAULT_DAEMON_URL);
});
ipcTest("discovers the live daemon URL via the sidecar IPC status socket", async () => {
const namespace = "discover-test";
const namespaceDir = path.join(ipcBaseDir, namespace);
fs.mkdirSync(namespaceDir, { recursive: true });
const socketPath = path.join(namespaceDir, "daemon.sock");
it("discovers the live daemon URL via the concrete sidecar IPC status endpoint", async () => {
const socketPath = process.platform === "win32"
? `\\\\.\\pipe\\open-design-daemon-url-${process.pid}-${Date.now()}`
: path.join(ipcBaseDir, "daemon.sock");
let ipc: JsonIpcServerHandle | null = null;
try {
ipc = await createJsonIpcServer({
@ -87,10 +77,9 @@ describe("resolveMcpDaemonUrl", () => {
},
});
const url = await resolveMcpDaemonUrl({
const url = await resolveDaemonUrl({
env: {
[SIDECAR_ENV.IPC_BASE]: ipcBaseDir,
[SIDECAR_ENV.NAMESPACE]: namespace,
[SIDECAR_ENV.IPC_PATH]: socketPath,
},
timeoutMs: 1000,
});

View file

@ -0,0 +1,13 @@
# apps/daemon/tests/fixtures/plugin-fixtures
Declarative plugin fixtures used by Phase 1 plugin-system tests
(`docs/plans/plugins-implementation.md` Phase 1 e2e-1).
Each subfolder is a self-contained Open Design plugin (per
`docs/plugins-spec.md` §5) ready to be passed to
`od plugin install --source <path>`.
- `sample-plugin/` — minimal `open-design.json` + companion `SKILL.md`.
The sidecar has primary precedence; the `SKILL.md` exists so the
daemon's compat adapter can be tested in isolation by deleting
`open-design.json`.

View file

@ -0,0 +1,23 @@
---
name: sample-plugin
description: Phase 1 sample plugin synthesizing a SKILL.md frontmatter for backwards-compat tests.
od:
kind: skill
taskKind: new-generation
preview:
type: deck
---
# Sample Plugin
This is the SKILL.md half of the Phase 1 e2e fixture. The companion
`open-design.json` sidecar carries the canonical Open Design plugin
manifest fields; this file proves the SKILL-only adapter path stays
honest when an install lacks an explicit sidecar (just delete
`open-design.json` to test the legacy compat tier).
## Workflow
1. Acknowledge the user's brief via the discovery question form atom.
2. Use TodoWrite to plan the brief.
3. Emit the brief as a deck artifact.

View file

@ -0,0 +1,26 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "sample-plugin",
"title": "Sample Plugin",
"version": "1.0.0",
"description": "Phase 1 e2e fixture used by od plugin install/apply walkthroughs.",
"license": "MIT",
"tags": ["sample", "phase1"],
"od": {
"kind": "skill",
"taskKind": "new-generation",
"useCase": {
"query": "Generate a {{topic}} brief for {{audience}}."
},
"context": {
"skills": [{ "ref": "open-design-landing" }],
"atoms": ["todo-write", "discovery-question-form"]
},
"inputs": [
{ "name": "topic", "type": "string", "required": true, "label": "Topic" },
{ "name": "audience", "type": "select", "options": ["VC pitch", "general"], "default": "general" }
],
"capabilities": ["prompt:inject"]
}
}

View file

@ -3,7 +3,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import express from 'express';
import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto';
import { SIDECAR_ENV } from '@open-design/sidecar-proto';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { isLocalSameOrigin } from '../src/origin-validation.js';
import { buildMcpInstallPayload } from '../src/mcp-install-info.js';
@ -20,7 +20,7 @@ interface InstallInfoOpts {
cliPath: string;
port: number;
/** Stand-in for `process.env`. Lets each test simulate sidecar vs
* non-sidecar daemon launches and custom namespaces without
* non-sidecar daemon launches and custom transport endpoints without
* mutating the real process env. */
env?: NodeJS.ProcessEnv;
/** Stand-in for the daemon's resolved RUNTIME_DATA_DIR (issue #848).
@ -72,14 +72,7 @@ function makeInstallInfoApp({ cliPath, port, env = {}, dataDir }: InstallInfoOpt
const isSidecarMode = sidecarIpcPath != null && sidecarIpcPath.length > 0;
const sidecarEnv: Record<string, string> = {};
if (isSidecarMode) {
const ns = env[SIDECAR_ENV.NAMESPACE];
if (ns != null && ns !== SIDECAR_DEFAULTS.namespace) {
sidecarEnv[SIDECAR_ENV.NAMESPACE] = ns;
}
const ipcBase = env[SIDECAR_ENV.IPC_BASE];
if (ipcBase != null && ipcBase.length > 0) {
sidecarEnv[SIDECAR_ENV.IPC_BASE] = ipcBase;
}
sidecarEnv[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath;
}
const payload = buildMcpInstallPayload({
cliPath,
@ -249,12 +242,11 @@ describe('GET /api/mcp/install-info', () => {
expect(after - before).toBeLessThanOrEqual(1);
});
it('sidecar default namespace omits --daemon-url and emits only OD_DATA_DIR', async () => {
it('sidecar launch omits --daemon-url and emits the concrete IPC path with OD_DATA_DIR', async () => {
const { port, server } = await startHarness(
cliPath,
{
[SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/default/daemon.sock',
[SIDECAR_ENV.NAMESPACE]: SIDECAR_DEFAULTS.namespace,
},
dataDir,
);
@ -262,41 +254,37 @@ describe('GET /api/mcp/install-info', () => {
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
const body = await readInstallInfo(res);
expect(body.args).toEqual([cliPath, 'mcp']);
// Default namespace + default IPC base means the spawned `od mcp`
// can derive the right socket without any sidecar env hints. The
// OD_DATA_DIR pin still rides along so the data dir is correct.
expect(body.env).toEqual({ OD_DATA_DIR: dataDir });
} finally {
await new Promise<void>((done) => server?.close(() => done()));
}
});
it('sidecar non-default namespace propagates OD_SIDECAR_NAMESPACE alongside OD_DATA_DIR', async () => {
const { port, server } = await startHarness(
cliPath,
{
[SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/foo/daemon.sock',
[SIDECAR_ENV.NAMESPACE]: 'foo',
},
dataDir,
);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
const body = await readInstallInfo(res);
expect(body.args).toEqual([cliPath, 'mcp']);
// Without this propagation the MCP client would launch `od mcp`
// with no namespace env, fall back to "default", and miss the
// foo daemon entirely.
expect(body.env).toEqual({
OD_DATA_DIR: dataDir,
[SIDECAR_ENV.NAMESPACE]: 'foo',
[SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/default/daemon.sock',
});
} finally {
await new Promise<void>((done) => server?.close(() => done()));
}
});
it('sidecar with custom IPC base propagates OD_SIDECAR_IPC_BASE alongside OD_DATA_DIR', async () => {
it('sidecar non-default endpoint still propagates only the concrete IPC path', async () => {
const { port, server } = await startHarness(
cliPath,
{
[SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/foo/daemon.sock',
},
dataDir,
);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
const body = await readInstallInfo(res);
expect(body.args).toEqual([cliPath, 'mcp']);
expect(body.env).toEqual({
OD_DATA_DIR: dataDir,
[SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/foo/daemon.sock',
});
} finally {
await new Promise<void>((done) => server?.close(() => done()));
}
});
it('sidecar with custom IPC base does not propagate namespace or base hints', async () => {
const { port, server } = await startHarness(
cliPath,
{
@ -311,8 +299,7 @@ describe('GET /api/mcp/install-info', () => {
const body = await readInstallInfo(res);
expect(body.env).toEqual({
OD_DATA_DIR: dataDir,
[SIDECAR_ENV.NAMESPACE]: 'foo',
[SIDECAR_ENV.IPC_BASE]: '/var/run/open-design',
[SIDECAR_ENV.IPC_PATH]: '/var/run/open-design/foo/daemon.sock',
});
} finally {
await new Promise<void>((done) => server?.close(() => done()));

View file

@ -0,0 +1,128 @@
// Daemon `applyPlugin` purity test (plan F4).
//
// applyPlugin must:
// - Compute a deterministic snapshot for the same inputs.
// - Refuse to mutate the registry / FS / SQLite — caller owns persistence.
// - Throw MissingInputError when a required input is absent.
import { describe, expect, it } from 'vitest';
import path from 'node:path';
import { applyPlugin, MissingInputError } from '../src/plugins/apply.js';
import { defaultRegistryRoots } from '../src/plugins/registry.js';
import { TRUSTED_DEFAULT_CAPABILITIES } from '../src/plugins/trust.js';
import type { ContextItem, InstalledPluginRecord } from '@open-design/contracts';
function pluginFixture(extra: Partial<InstalledPluginRecord> = {}): InstalledPluginRecord {
return {
id: 'sample-plugin',
title: 'Sample Plugin',
version: '1.0.0',
sourceKind: 'local',
source: '/tmp/sample-plugin',
sourceMarketplaceId: undefined,
pinnedRef: undefined,
sourceDigest: undefined,
trust: 'trusted',
capabilitiesGranted: ['prompt:inject'],
fsPath: '/tmp/sample-plugin',
installedAt: 0,
updatedAt: 0,
manifest: {
name: 'sample-plugin',
title: 'Sample Plugin',
version: '1.0.0',
description: 'Fixture for apply tests.',
od: {
kind: 'skill',
taskKind: 'new-generation',
useCase: { query: 'Generate a {{topic}} brief.' },
inputs: [
{ name: 'topic', type: 'string', required: true },
{ name: 'audience', type: 'string', default: 'general' },
],
context: {
skills: [{ ref: 'sample-skill' }],
atoms: ['todo-write'],
},
capabilities: ['prompt:inject'],
},
},
...extra,
};
}
const REGISTRY = {
skills: [{ id: 'sample-skill', title: 'Sample Skill' }],
designSystems: [],
craft: [],
atoms: [{ id: 'todo-write', label: 'Todo write' }],
};
describe('applyPlugin', () => {
it('produces a deterministic snapshot for the same inputs', () => {
const a = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
const b = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
expect(a.manifestSourceDigest).toBe(b.manifestSourceDigest);
expect(a.result.appliedPlugin.manifestSourceDigest).toBe(b.result.appliedPlugin.manifestSourceDigest);
expect(a.result.appliedPlugin.appliedAt).not.toBe(0);
});
it('throws MissingInputError when a required input is missing', () => {
expect(() => applyPlugin({ plugin: pluginFixture(), inputs: {}, registry: REGISTRY })).toThrow(MissingInputError);
});
it('coerces optional inputs by defaulting when blank', () => {
const result = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
expect(result.result.appliedPlugin.inputs.audience).toBe('general');
});
it('resolves localized use-case queries at apply time', () => {
const base = pluginFixture();
const result = applyPlugin({
plugin: {
...base,
manifest: {
...base.manifest,
od: {
...base.manifest.od,
useCase: {
query: {
en: 'Generate a {{topic}} brief.',
'zh-CN': '生成一份关于 {{topic}} 的简报。',
},
},
},
},
},
inputs: { topic: 'design' },
registry: REGISTRY,
locale: 'zh-CN',
});
expect(result.result.query).toBe('生成一份关于 {{topic}} 的简报。');
expect(result.result.appliedPlugin.query).toBe('生成一份关于 {{topic}} 的简报。');
});
it('grants trusted defaults plus required caps for a trusted plugin', () => {
const result = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
for (const cap of TRUSTED_DEFAULT_CAPABILITIES) {
expect(result.result.capabilitiesGranted).toContain(cap);
}
});
it('emits skill+atom items in resolvedContext.items', () => {
const result = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
const kinds = result.result.contextItems.map((c: ContextItem) => c.kind);
expect(kinds).toContain('skill');
expect(kinds).toContain('atom');
});
it('does not require a registry roots argument (no FS access at apply time)', () => {
// Sanity: the function must not reach for the on-disk plugin folder.
const roots = defaultRegistryRoots();
const expectedDataDir = path.resolve(process.env.OD_DATA_DIR ?? path.join(process.cwd(), '.od'));
expect(roots.userPluginsRoot).toBe(path.join(expectedDataDir, 'plugins'));
const result = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
expect(result.result.appliedPlugin.pluginId).toBe('sample-plugin');
});
});

View file

@ -0,0 +1,120 @@
// Plan §3.L3 / spec §10.3.5 / §9.2 — plugin asset endpoint.
//
// Validates the daemon-side half of the SandboxedComponentSurface
// contract:
//
// - 404 when the plugin id is unknown.
// - 400 when the relpath includes traversal segments.
// - 200 with the §9.2 CSP + nosniff headers when the asset is
// served from a real fsPath.
// - Requests outside the plugin's fsPath are refused even when the
// normalized path resolves to an existing file elsewhere.
import type http from 'node:http';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { startServer } from '../src/server.js';
import { migratePlugins } from '../src/plugins/persistence.js';
import { upsertInstalledPlugin } from '../src/plugins/registry.js';
let server: http.Server;
let baseUrl: string;
let shutdown: (() => Promise<void> | void) | undefined;
let pluginRoot: string;
beforeAll(async () => {
pluginRoot = await mkdtemp(path.join(os.tmpdir(), 'od-asset-'));
const surfacesDir = path.join(pluginRoot, 'surfaces');
await mkdir(surfacesDir, { recursive: true });
await writeFile(
path.join(surfacesDir, 'index.html'),
'<!DOCTYPE html><title>fixture</title><script>console.log(1)</script>',
);
await writeFile(
path.join(pluginRoot, 'open-design.json'),
JSON.stringify({
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
name: 'asset-plugin',
title: 'Asset',
version: '1.0.0',
description: 'fixture',
license: 'MIT',
od: { kind: 'skill', capabilities: ['prompt:inject', 'genui:custom-component'] },
}),
);
const started = (await startServer({ port: 0, returnServer: true })) as {
url: string;
server: http.Server;
shutdown?: () => Promise<void> | void;
};
baseUrl = started.url;
server = started.server;
shutdown = started.shutdown;
// Insert the plugin row into the running daemon's DB. We can't reach
// the daemon's `db` handle directly, so we open a sibling SQLite
// session against the same RUNTIME_DATA_DIR. Instead, simulate the
// installer's effect by hitting the install API:
//
// For test simplicity we open a private DB and skip the daemon's
// registry. The asset route reads through `getInstalledPlugin(db,…)`
// backed by the daemon's own DB, so we must use the install route.
// But install requires SAFE_BASENAME id matching the folder name —
// achievable by pointing at our prepared fixture.
const installResp = await fetch(`${baseUrl}/api/plugins/install`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
body: JSON.stringify({ source: pluginRoot }),
});
// Drain SSE.
if (installResp.body) {
const reader = installResp.body.getReader();
while (true) {
const { done } = await reader.read();
if (done) break;
}
}
void migratePlugins;
void upsertInstalledPlugin;
void Database;
});
afterAll(async () => {
await Promise.resolve(shutdown?.());
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(pluginRoot, { recursive: true, force: true });
});
describe('GET /api/plugins/:id/asset/*', () => {
it('returns 404 for an unknown plugin', async () => {
const resp = await fetch(`${baseUrl}/api/plugins/unknown/asset/index.html`);
expect(resp.status).toBe(404);
});
it('rejects path-traversal segments with 400', async () => {
const resp = await fetch(`${baseUrl}/api/plugins/asset-plugin/asset/..%2Fescape`);
expect(resp.status).toBe(400);
});
it('serves an asset with the §9.2 preview CSP + nosniff', async () => {
const resp = await fetch(`${baseUrl}/api/plugins/asset-plugin/asset/surfaces/index.html`);
expect(resp.status).toBe(200);
const csp = resp.headers.get('content-security-policy') ?? '';
expect(csp).toContain("default-src 'none'");
expect(csp).toContain("connect-src 'none'");
expect(csp).toContain("frame-ancestors 'self'");
expect(resp.headers.get('x-content-type-options')).toBe('nosniff');
expect(resp.headers.get('content-type')).toMatch(/text\/html/);
const body = await resp.text();
expect(body).toContain('fixture');
});
it('returns 404 for a missing asset under a known plugin', async () => {
const resp = await fetch(`${baseUrl}/api/plugins/asset-plugin/asset/does/not/exist.html`);
expect(resp.status).toBe(404);
});
});

View file

@ -0,0 +1,94 @@
// Phase 4 / spec §23.3.2 patch 2 — atom SKILL.md body loader.
//
// The substrate slice for lifting `composeSystemPrompt`'s prompt
// constants into the bundled atom plugins. The daemon-side helper
// reads `<bundled-fsPath>/SKILL.md` and strips frontmatter; the
// pure renderer in @open-design/contracts then assembles the stage
// prompt block. This test pins both halves of the contract so a
// future PR that lifts system.ts has zero scaffolding to build.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { migratePlugins } from '../src/plugins/persistence.js';
import { registerBundledPlugins } from '../src/plugins/bundled.js';
import { loadAtomBodies } from '../src/plugins/atom-bodies.js';
import { renderActiveStageBlock } from '@open-design/contracts';
const SAMPLE_MANIFEST = (id: string) =>
JSON.stringify({
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
name: id,
title: id,
version: '0.1.0',
description: `${id} fixture`,
license: 'MIT',
od: { kind: 'atom', capabilities: ['prompt:inject'] },
});
const SAMPLE_SKILL = (id: string, body: string) =>
`---\nname: ${id}\ndescription: ${id} fixture\n---\n\n# ${id}\n\n${body}\n`;
let db: Database.Database;
let tmpRoot: string;
beforeEach(async () => {
tmpRoot = await mkdtemp(path.join(os.tmpdir(), 'od-atom-bodies-'));
db = new Database(':memory:');
db.exec(`
CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE conversations (id TEXT PRIMARY KEY, project_id TEXT, title TEXT);
`);
migratePlugins(db);
// Build a minimal bundled root with two atom plugins so the loader has
// something to find.
const atomA = path.join(tmpRoot, 'atoms', 'discovery-question-form');
const atomB = path.join(tmpRoot, 'atoms', 'todo-write');
await mkdir(atomA, { recursive: true });
await mkdir(atomB, { recursive: true });
await writeFile(path.join(atomA, 'open-design.json'), SAMPLE_MANIFEST('discovery-question-form'));
await writeFile(path.join(atomA, 'SKILL.md'), SAMPLE_SKILL('discovery-question-form', 'Ask the user about audience.'));
await writeFile(path.join(atomB, 'open-design.json'), SAMPLE_MANIFEST('todo-write'));
await writeFile(path.join(atomB, 'SKILL.md'), SAMPLE_SKILL('todo-write', 'Commit a numbered plan.'));
await registerBundledPlugins({ db, bundledRoot: tmpRoot });
});
afterEach(async () => {
db.close();
await rm(tmpRoot, { recursive: true, force: true });
});
describe('loadAtomBodies', () => {
it('reads SKILL.md bodies for bundled atoms (frontmatter stripped)', async () => {
const out = await loadAtomBodies(db, ['discovery-question-form', 'todo-write']);
expect(out.map((e) => e.atomId)).toEqual(['discovery-question-form', 'todo-write']);
expect(out[0]!.body).toContain('# discovery-question-form');
expect(out[0]!.body).toContain('Ask the user about audience.');
expect(out[0]!.body.startsWith('---')).toBe(false);
});
it('skips ids without an installed plugin or readable SKILL.md', async () => {
const out = await loadAtomBodies(db, ['unknown-atom', 'todo-write']);
expect(out.map((e) => e.atomId)).toEqual(['todo-write']);
});
it('returns an empty array for an empty input', async () => {
expect(await loadAtomBodies(db, [])).toEqual([]);
});
});
describe('renderActiveStageBlock + loadAtomBodies (end-to-end stage block)', () => {
it('builds a `## Active stage` header followed by every atom body', async () => {
const bodies = await loadAtomBodies(db, ['discovery-question-form', 'todo-write']);
const block = renderActiveStageBlock({ stageId: 'plan', bodies });
expect(block).toContain('## Active stage: plan');
expect(block).toContain('### discovery-question-form');
expect(block).toContain('Ask the user about audience.');
expect(block).toContain('### todo-write');
expect(block).toContain('Commit a numbered plan.');
});
});

View file

@ -0,0 +1,248 @@
// Stage D of plugin-driven-flow-plan — atom worker registry tests.
//
// Covers:
// - Registration / lookup / clearing surface.
// - `runStageWithRegistry` aggregates signals across multiple atoms
// using pessimistic merge (lowest number / false-wins boolean).
// - Permissive defaults preserve happy-path convergence when no
// atom registers a real worker.
// - Worker errors are captured as notes instead of crashing the
// stage so a bad atom never blocks the run.
// - The built-in `critique-theater` worker reads
// `run_devloop_iterations` and surfaces the lowest numeric score
// it can find — and falls through silently when no score is
// present yet (e.g. the first iteration before the agent has
// written a critique).
// - Built-in registration is idempotent and the test-only reset
// hook restores the install flag for the next case.
//
// The registry is module-scoped so each `beforeEach` calls
// `clearAtomWorkers()` + `resetBuiltInAtomWorkersForTests()` to
// guarantee independence between cases.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import type { AppliedPluginSnapshot, PipelineStage } from '@open-design/contracts';
import { migratePlugins } from '../src/plugins/persistence.js';
import {
PERMISSIVE_DEFAULT_SIGNALS,
clearAtomWorkers,
getAtomWorker,
listRegisteredAtomIds,
registerAtomWorker,
runStageWithRegistry,
type AtomWorkerContext,
} from '../src/plugins/atoms/registry.js';
import {
registerBuiltInAtomWorkers,
resetBuiltInAtomWorkersForTests,
} from '../src/plugins/atoms/built-ins.js';
let db: Database.Database;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'od-atom-reg-'));
db = new Database(path.join(tmpDir, 'test.sqlite'));
db.exec(`
CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE conversations (id TEXT PRIMARY KEY, project_id TEXT, title TEXT);
`);
migratePlugins(db);
clearAtomWorkers();
resetBuiltInAtomWorkersForTests();
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
function fakeSnapshot(): AppliedPluginSnapshot {
return {
snapshotId: 'snap-1',
projectId: 'project-1',
conversationId: 'conv-A',
runId: 'run-1',
pluginId: 'sample-plugin',
pluginVersion: '1.0.0',
pluginTitle: 'Sample',
pluginDescription: '',
manifestSourceDigest: 'digest-1',
taskKind: 'new-generation',
inputs: {},
resolvedContext: { items: [] },
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
assetsStaged: [],
connectorsRequired: [],
connectorsResolved: [],
mcpServers: [],
query: '',
createdAt: Date.now(),
} as unknown as AppliedPluginSnapshot;
}
function ctxFor(stage: PipelineStage, iteration = 0): AtomWorkerContext {
return {
db,
runId: 'run-1',
projectId: 'project-1',
conversationId: 'conv-A',
stage,
iteration,
snapshot: fakeSnapshot(),
};
}
describe('atom registry: registration + lookup', () => {
it('returns undefined for unregistered ids and lists registered ones alphabetically', () => {
expect(getAtomWorker('nope')).toBeUndefined();
registerAtomWorker({ id: 'zebra', run: () => ({}) });
registerAtomWorker({ id: 'alpha', run: () => ({}) });
expect(listRegisteredAtomIds()).toEqual(['alpha', 'zebra']);
});
it('clears the registry on demand', () => {
registerAtomWorker({ id: 'temp', run: () => ({}) });
expect(listRegisteredAtomIds()).toContain('temp');
clearAtomWorkers();
expect(listRegisteredAtomIds()).toEqual([]);
});
});
describe('runStageWithRegistry: signal aggregation', () => {
it('falls through to permissive defaults when no atom has a registered worker', async () => {
const stage: PipelineStage = { id: 's', atoms: ['unknown-1', 'unknown-2'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals).toEqual(PERMISSIVE_DEFAULT_SIGNALS);
expect(out.observedAtoms).toEqual([]);
expect(out.notes).toEqual([]);
expect(out.critiqueSummary).toBeNull();
});
it('pessimistically merges numeric signals (lowest wins) across multiple atoms', async () => {
registerAtomWorker({
id: 'judge-low',
run: () => ({ signals: { 'critique.score': 2 } }),
});
registerAtomWorker({
id: 'judge-high',
run: () => ({ signals: { 'critique.score': 5 } }),
});
const stage: PipelineStage = { id: 's', atoms: ['judge-low', 'judge-high'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['critique.score']).toBe(2);
expect(out.observedAtoms).toEqual(['judge-low', 'judge-high']);
});
it('pessimistically merges boolean signals (false wins) across atoms', async () => {
registerAtomWorker({
id: 'gate-pass',
run: () => ({ signals: { 'preview.ok': true } }),
});
registerAtomWorker({
id: 'gate-fail',
run: () => ({ signals: { 'preview.ok': false } }),
});
const stage: PipelineStage = { id: 's', atoms: ['gate-pass', 'gate-fail'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['preview.ok']).toBe(false);
});
it('captures worker errors as notes without crashing the stage', async () => {
registerAtomWorker({
id: 'boom',
run: () => {
throw new Error('boom');
},
});
registerAtomWorker({
id: 'ok',
run: () => ({ signals: { 'critique.score': 4 }, note: 'looks fine' }),
});
const stage: PipelineStage = { id: 's', atoms: ['boom', 'ok'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['critique.score']).toBe(4);
expect(out.notes.some((n) => n.includes('worker error: boom'))).toBe(true);
expect(out.notes.some((n) => n.includes('looks fine'))).toBe(true);
expect(out.critiqueSummary).not.toBeNull();
});
it('awaits async workers and applies their signals to the merge', async () => {
registerAtomWorker({
id: 'slow-judge',
run: async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
return { signals: { 'critique.score': 1 } };
},
});
const stage: PipelineStage = { id: 's', atoms: ['slow-judge'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['critique.score']).toBe(1);
});
});
describe('built-in critique-theater worker', () => {
beforeEach(() => {
registerBuiltInAtomWorkers();
});
it('returns permissive (no score) when no devloop row exists yet', async () => {
const stage: PipelineStage = { id: 'critique', atoms: ['critique-theater'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals).toEqual(PERMISSIVE_DEFAULT_SIGNALS);
expect(out.observedAtoms).toEqual(['critique-theater']);
expect(out.critiqueSummary).toBeNull();
});
it('parses a numeric score=N from critique_summary and surfaces the latest iteration that has one', async () => {
insertIteration('critique', 1, 'critique panel: score=2');
insertIteration('critique', 2, 'no parseable score here');
insertIteration('critique', 3, 'score=4');
const stage: PipelineStage = { id: 'critique', atoms: ['critique-theater'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['critique.score']).toBe(4);
expect(out.critiqueSummary).toContain('latest critique score=4 from iteration 3');
});
it('falls back to the most recent parseable iteration when the newest row has no score', async () => {
insertIteration('critique', 1, 'score=3');
insertIteration('critique', 2, 'unrelated notes only');
const stage: PipelineStage = { id: 'critique', atoms: ['critique-theater'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['critique.score']).toBe(3);
});
it('only looks at iterations for the active stage, ignoring siblings', async () => {
insertIteration('discovery', 1, 'score=1');
insertIteration('critique', 1, 'score=4');
const stage: PipelineStage = { id: 'critique', atoms: ['critique-theater'] };
const out = await runStageWithRegistry(ctxFor(stage));
expect(out.signals['critique.score']).toBe(4);
});
});
describe('registerBuiltInAtomWorkers: idempotency', () => {
it('registers every FIRST_PARTY_ATOM exactly once even on repeat calls', () => {
registerBuiltInAtomWorkers();
const first = listRegisteredAtomIds();
registerBuiltInAtomWorkers();
const second = listRegisteredAtomIds();
expect(second).toEqual(first);
expect(first).toContain('critique-theater');
expect(first).toContain('file-write');
expect(first).toContain('media-image');
});
});
function insertIteration(stageId: string, iteration: number, summary: string): void {
db.prepare(
`INSERT INTO run_devloop_iterations
(id, run_id, stage_id, iteration, artifact_diff_summary, critique_summary, tokens_used, ended_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(`iter-${stageId}-${iteration}`, 'run-1', stageId, iteration, null, summary, null, Date.now());
}

View file

@ -0,0 +1,42 @@
// Plan §3.AA2 — atoms catalog promotion + atom info.
import { describe, expect, it } from 'vitest';
import { findAtom, FIRST_PARTY_ATOMS, isImplementedAtom } from '../src/plugins/atoms.js';
describe('atoms catalog — Phase 6/7/8 promotion', () => {
const promotedIds = [
'code-import',
'design-extract',
'figma-extract',
'token-map',
'rewrite-plan',
'patch-edit',
'build-test',
'diff-review',
'handoff',
];
it.each(promotedIds)('atom %s is now status=implemented', (id) => {
const atom = findAtom(id);
expect(atom).toBeDefined();
expect(atom?.status).toBe('implemented');
expect(isImplementedAtom(id)).toBe(true);
});
it("'build-test' is registered with the matching daemon impl", () => {
const atom = findAtom('build-test');
expect(atom?.label).toMatch(/Build/);
expect(atom?.taskKinds).toContain('code-migration');
});
it('the catalog has no remaining planned atoms (after the §3.AA2 promotion)', () => {
const planned = FIRST_PARTY_ATOMS.filter((a) => a.status === 'planned');
expect(planned.map((a) => a.id)).toEqual([]);
});
it('every atom in the catalog has a non-empty taskKinds[]', () => {
for (const atom of FIRST_PARTY_ATOMS) {
expect(atom.taskKinds.length).toBeGreaterThan(0);
}
});
});

Some files were not shown because too many files have changed in this diff Show more