diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 1e92608d2..be7663305 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -78,6 +78,15 @@
"metadata": {
"recommended": false
}
+ },
+ {
+ "name": "aidd-qa",
+ "source": "./plugins/aidd-qa",
+ "description": "Acceptance QA: validates observable behavior against acceptance criteria and records reviewer evidence. Browser is the only supported interface today.",
+ "strict": true,
+ "metadata": {
+ "recommended": false
+ }
}
]
}
diff --git a/.claude/hooks/check-written-file.js b/.claude/hooks/check-written-file.js
new file mode 100644
index 000000000..0d66fd46c
--- /dev/null
+++ b/.claude/hooks/check-written-file.js
@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+// PostToolUse hook — checks a file an agent just wrote under `cli/` with the same Biome check
+// pre-commit runs, so a broken rule comes back in the same turn instead of at the next gate.
+//
+// Docs: https://code.claude.com/docs/en/hooks#posttooluse
+// stdin : JSON { tool_input: { file_path } }
+// exit 2: the Biome report on stderr, which Claude Code hands back to the agent
+// exit 0: silence — a clean file, one outside `cli/`, or no Biome installed to ask
+
+const { spawnSync } = require("node:child_process");
+const { existsSync, lstatSync, readFileSync } = require("node:fs");
+const path = require("node:path");
+
+const CLI = path.resolve(__dirname, "..", "..", "cli");
+const WINDOWS = process.platform === "win32";
+const BIOME = path.join(CLI, "node_modules", ".bin", WINDOWS ? "biome.cmd" : "biome");
+const CHECKED = /\.(ts|mts|cts|js|mjs|cjs|json|jsonc)$/;
+
+function writtenFile() {
+ try {
+ return JSON.parse(readFileSync(0, "utf8")).tool_input?.file_path ?? "";
+ } catch {
+ return "";
+ }
+}
+
+function main() {
+ const file = writtenFile();
+ if (file === "" || !CHECKED.test(file) || !existsSync(file) || lstatSync(file).isSymbolicLink()) {
+ return 0;
+ }
+ const relative = path.relative(CLI, path.resolve(file));
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return 0;
+ if (relative.split(path.sep).includes("node_modules") || !existsSync(BIOME)) return 0;
+ const result = spawnSync(BIOME, ["check", "--write", "--no-errors-on-unmatched", "--diagnostic-level=error", relative], {
+ cwd: CLI,
+ encoding: "utf8",
+ shell: WINDOWS,
+ });
+ if (result.status === 0) return 0;
+ process.stderr.write(`${result.stdout}${result.stderr}`);
+ return 2;
+}
+
+process.exitCode = main();
diff --git a/.claude/settings.json b/.claude/settings.json
index f5ebe8d8d..1850d4990 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -10,6 +10,18 @@
}
]
}
+ ],
+ "PostToolUse": [
+ {
+ "matcher": "Edit|Write|MultiEdit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-written-file.js\"",
+ "timeout": 60
+ }
+ ]
+ }
]
},
"enabledPlugins": {
diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json
index 72de2e226..be73312c0 100644
--- a/.github/rulesets/main.json
+++ b/.github/rulesets/main.json
@@ -16,6 +16,7 @@
{
"type": "pull_request",
"parameters": {
+ "allowed_merge_methods": ["merge"],
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": true,
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 25b6d331f..0ee0bfacd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -53,6 +53,9 @@ jobs:
always() &&
github.event_name == 'push' &&
(needs.commitlint.result == 'success' || needs.commitlint.result == 'skipped')
+ # contents: read for the credit step's checkout.
+ permissions:
+ contents: read
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
@@ -75,11 +78,12 @@ jobs:
# bypass actor. The App token also re-fires the `push: main` and `release: published`
# workflows a GITHUB_TOKEN merge would not. Guarded on prs_created, since
# releases_created only fires on merge.
+ # `--merge`: main allows only merge commits. `--body ""`: a PR-title body is parsed as a 2nd commit.
- name: Auto-merge the Release PR
if: ${{ steps.release.outputs.prs_created == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
- run: gh pr merge "${{ fromJSON(steps.release.outputs.pr).number }}" --squash --admin --repo "${{ github.repository }}"
+ run: gh pr merge "${{ fromJSON(steps.release.outputs.pr).number }}" --merge --admin --body "" --repo "${{ github.repository }}"
# GitHub marks whichever release is created last as "Latest", and release-please
# creates the umbrella and every plugin release in one unordered run — so a plugin
@@ -91,6 +95,22 @@ jobs:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: gh release edit "${{ steps.release.outputs.tag_name }}" --latest --repo "${{ github.repository }}"
+ # Credits commit authors; native `include-commit-authors` is broken (release-please#2761).
+ # Remove with the script once #2892 ships. continue-on-error: never blocks the publish jobs.
+ - name: Checkout
+ if: ${{ steps.release.outputs.releases_created == 'true' }}
+ continue-on-error: true
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Credit contributors on the created release(s)
+ if: ${{ steps.release.outputs.releases_created == 'true' }}
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ RELEASE_OUTPUTS: ${{ toJSON(steps.release.outputs) }}
+ run: node scripts/credit-release-authors.cjs "${{ github.repository }}"
+
build-and-attach:
name: Build and attach marketplace
needs: [release-please]
@@ -134,7 +154,7 @@ jobs:
contents: write
strategy:
fail-fast: false
- # Four marketplace plus five flat — opencode is flat-only. Mirrors the CLI golden
+ # Four marketplace plus six flat — opencode and kilo are flat-only. Mirrors the CLI golden
# snapshot matrix.
matrix:
include:
@@ -147,6 +167,7 @@ jobs:
- { tool: copilot, mode: flat }
- { tool: codex, mode: flat }
- { tool: opencode, mode: flat }
+ - { tool: kilo, mode: flat }
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -211,6 +232,7 @@ jobs:
aidd-refine,
aidd-ui,
aidd-telemetry,
+ aidd-qa,
]
steps:
- name: Check if this plugin was released
diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml
index 9d223e5ac..fa042e3de 100644
--- a/.github/workflows/cli-ci.yml
+++ b/.github/workflows/cli-ci.yml
@@ -21,14 +21,20 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
permissions:
+ actions: read
contents: read
jobs:
changes:
name: cli / changes
+ permissions:
+ actions: read
+ contents: read
+ pull-requests: read
runs-on: ubuntu-latest
outputs:
relevant: ${{ steps.filter.outputs.relevant }}
+ trusted_promotion: ${{ steps.promotion.outputs.trusted }}
mutation_scopes: ${{ steps.mutation.outputs.scopes }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -81,25 +87,120 @@ jobs:
echo "relevant=$relevant" >> "$GITHUB_OUTPUT"
+ # A promotion branch is a snapshot of next, not the live next branch. Reuse only proves
+ # a tree already gated on next: every missing Git or API proof falls back to mutations.
+ - name: Check whether a promotion snapshot or main merge passed next
+ id: promotion
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ EVENT_NAME: ${{ github.event_name }}
+ GITHUB_REF: ${{ github.ref }}
+ CURRENT_SHA: ${{ github.sha }}
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_REF: ${{ github.event.pull_request.head.ref }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
+ run: |
+ set -euo pipefail
+ trusted=false
+ reason="not a reusable promotion snapshot"
+
+ has_successful_next_gate() {
+ local run_ids run_id gate
+ reason="no successful cli CI push run found for the snapshot SHA"
+ if run_ids="$(gh api --paginate "/repos/$REPO/actions/workflows/cli-ci.yml/runs?branch=next&event=push&status=completed&head_sha=$HEAD_SHA&per_page=100" \
+ --jq '.workflow_runs[] | select(.conclusion == "success") | .id' 2>/dev/null)"; then
+ while IFS= read -r run_id; do
+ [[ -z "$run_id" ]] && continue
+ if gate="$(gh api "/repos/$REPO/actions/runs/$run_id/jobs?filter=latest&per_page=100" \
+ --jq 'any(.jobs[]; .name == "cli / gate" and .conclusion == "success")' 2>/dev/null)"; then
+ if [[ "$gate" == "true" ]]; then
+ reason="reusing the successful cli CI gate from next run $run_id"
+ return 0
+ fi
+ reason="cli / gate did not pass in next run $run_id"
+ else
+ reason="could not inspect cli CI run $run_id"
+ fi
+ done <<< "$run_ids"
+ else
+ reason="could not list successful cli CI push runs"
+ fi
+ return 1
+ }
+
+ # GitHub tests pull requests at a synthetic merge ref. The source gate may be reused
+ # only if main is already in the snapshot and that merge ref has the same file tree.
+ if [[ "$EVENT_NAME" == "pull_request" && "$BASE_REF" == "main" && "$HEAD_REF" =~ ^promote/next-to-main-[0-9]+$ && "$HEAD_REPO" == "$REPO" ]]; then
+ if ! git merge-base --is-ancestor "$BASE_SHA" "$HEAD_SHA" 2>/dev/null; then
+ reason="main base is not an ancestor of the promotion snapshot"
+ elif ! snapshot_tree="$(git rev-parse "$HEAD_SHA^{tree}" 2>/dev/null)" || ! merge_tree="$(git rev-parse "$CURRENT_SHA^{tree}" 2>/dev/null)"; then
+ reason="could not prove the promotion merge tree"
+ elif [[ "$merge_tree" != "$snapshot_tree" ]]; then
+ reason="promotion merge tree differs from the snapshot"
+ elif has_successful_next_gate; then
+ trusted=true
+ fi
+ # A main push is reusable only for the exact merge of a numbered, same-repository
+ # promotion PR. Parent two is its source snapshot; neither a squash nor an unrelated
+ # merge can satisfy this proof.
+ elif [[ "$EVENT_NAME" == "push" && "$GITHUB_REF" == "refs/heads/main" ]]; then
+ if ! parents="$(git rev-list --parents -n 1 "$CURRENT_SHA" 2>/dev/null)" || ! read -r merge_sha first_parent snapshot_sha extra_parent <<< "$parents" || [[ "$merge_sha" != "$CURRENT_SHA" || -z "$first_parent" || -z "$snapshot_sha" || -n "${extra_parent:-}" ]]; then
+ reason="main commit is not a two-parent merge"
+ elif ! snapshot_tree="$(git rev-parse "$snapshot_sha^{tree}" 2>/dev/null)" || ! main_tree="$(git rev-parse "$CURRENT_SHA^{tree}" 2>/dev/null)"; then
+ reason="could not read the main merge or snapshot tree"
+ elif [[ "$main_tree" != "$snapshot_tree" ]]; then
+ reason="main merge tree differs from the promotion snapshot"
+ elif ! prs="$(gh api "/repos/$REPO/commits/$CURRENT_SHA/pulls" \
+ --jq '.[] | select(.base.ref == "main" and (.head.ref | test("^promote/next-to-main-[0-9]+$")) and .merged_at != null) | [.base.repo.full_name, .head.repo.full_name, .head.ref, .head.sha, .merged_at, .merge_commit_sha] | @tsv' 2>/dev/null)"; then
+ reason="could not inspect pull requests associated with the main commit"
+ else
+ matching_prs=0
+ while IFS=$'\t' read -r base_repo head_repo head_ref pr_head_sha merged_at merge_commit_sha; do
+ if [[ "$base_repo" == "$REPO" && "$head_repo" == "$REPO" && "$head_ref" =~ ^promote/next-to-main-[0-9]+$ && "$pr_head_sha" == "$snapshot_sha" && -n "$merged_at" && "$merge_commit_sha" == "$CURRENT_SHA" ]]; then
+ ((matching_prs += 1))
+ fi
+ done <<< "$prs"
+
+ if [[ "$matching_prs" -ne 1 ]]; then
+ reason="no unique matching promotion pull request proved this main merge"
+ else
+ HEAD_SHA="$snapshot_sha"
+ if has_successful_next_gate; then
+ trusted=true
+ fi
+ fi
+ fi
+ fi
+
+ echo "promotion mutation reuse: $reason"
+ echo "trusted=$trusted" >> "$GITHUB_OUTPUT"
+
- name: Decide which mutation scopes a change can move
id: mutation
run: |
set -euo pipefail
- if [[ "${{ github.event_name }}" == "pull_request" ]]; then
- BASE="${{ github.event.pull_request.base.sha }}"
- HEAD="${{ github.event.pull_request.head.sha }}"
- else
- BASE="${{ github.event.before }}"
- HEAD="${{ github.sha }}"
- fi
- if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]]; then
- changed=""
- all=true
+ if [[ "${{ steps.promotion.outputs.trusted }}" == "true" ]]; then
+ scopes='[]'
else
- changed="$(git diff --name-only "$BASE" "$HEAD")"
- all=false
+ if [[ "${{ github.event_name }}" == "pull_request" ]]; then
+ BASE="${{ github.event.pull_request.base.sha }}"
+ HEAD="${{ github.event.pull_request.head.sha }}"
+ else
+ BASE="${{ github.event.before }}"
+ HEAD="${{ github.sha }}"
+ fi
+ if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]]; then
+ changed=""
+ all=true
+ else
+ changed="$(git diff --name-only "$BASE" "$HEAD")"
+ all=false
+ fi
+ scopes="$(ALL="$all" CHANGED="$changed" node cli/scripts/mutation-scopes-to-run.mjs)"
fi
- scopes="$(ALL="$all" CHANGED="$changed" node cli/scripts/mutation-scopes-to-run.mjs)"
echo "mutation scopes: $scopes"
echo "scopes=$scopes" >> "$GITHUB_OUTPUT"
@@ -218,6 +319,32 @@ jobs:
# token and no network. `smoke:full` adds the remote-fetch section on demand.
- run: cd cli && pnpm smoke
+ cli-kilo-runtime:
+ name: cli / Kilo runtime smoke
+ needs: [changes]
+ if: needs.changes.outputs.relevant == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Install pnpm
+ run: corepack enable
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: "22"
+ - name: Cache pnpm store
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.local/share/pnpm/store
+ key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }}
+ restore-keys: pnpm-${{ runner.os }}-
+ - run: cd cli && pnpm install --frozen-lockfile
+ # The test starts Kilo's local server and asks it to load the generated project plugin.
+ # No model or account is needed for this protocol-level smoke.
+ - name: Install Kilo Code CLI
+ run: npm install -g @kilocode/cli@7.7.5
+ - run: cd cli && pnpm test:e2e:kilo
+
cli-build:
name: cli / Build & Bundle Budget
needs: [changes]
@@ -490,6 +617,7 @@ jobs:
- cli-architecture
- cli-coverage
- cli-smoke
+ - cli-kilo-runtime
- cli-build
- cli-knip
- identifier-join
@@ -510,6 +638,7 @@ jobs:
"${{ needs.cli-architecture.result }}" \
"${{ needs.cli-coverage.result }}" \
"${{ needs.cli-smoke.result }}" \
+ "${{ needs.cli-kilo-runtime.result }}" \
"${{ needs.cli-build.result }}" \
"${{ needs.cli-knip.result }}" \
"${{ needs.identifier-join.result }}" \
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 600a5211d..df3419176 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -30,15 +30,15 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Initialize CodeQL
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
+ uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
languages: ${{ matrix.language }}
queries: security-and-quality
- name: Autobuild
- uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
+ uses: github/codeql-action/autobuild@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
+ uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
with:
category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml
index 347c6371c..5e8824a92 100644
--- a/.github/workflows/promote.yml
+++ b/.github/workflows/promote.yml
@@ -67,8 +67,9 @@ jobs:
# `ci:` on purpose: the type is absent from release-please's changelog-sections,
# so this plumbing commit never surfaces in a changelog, and commitlint accepts it.
+ # `--body ""`: keeps the PR title out of the merge body, which release-please would re-parse.
gh pr merge "$PR" --repo "$REPO" --merge --auto --delete-branch \
- --subject "ci: promote next to main (#${PR})"
+ --subject "ci: promote next to main (#${PR})" --body ""
# Without a recorded subject GitHub generates one that is not conventional, and
# commitlint lints main's tip on push.
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index c54203832..5727ea153 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -8,5 +8,6 @@
"plugins/aidd-refine": "3.0.1",
"plugins/aidd-ui": "0.2.1-alpha.0",
"plugins/aidd-telemetry": "0.2.0",
+ "plugins/aidd-qa": "0.1.0",
"cli": "5.3.0"
}
diff --git a/README.md b/README.md
index 7507f3b57..760238e8d 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Unify **engineering teams** around **standardized workflows** and **shared best
🧱 **IDE agnostic** · 🏗️ **Legacy systems** · 🌱 **Token-optimized** · 🇫🇷 **Made in France**
- 8 plugins · 50 skills · 2 agents
+ 9 plugins · 51 skills · 2 agents
[](https://opensource.org/)
@@ -37,7 +37,7 @@ Why not just write your own commands? → [FAQ](docs/FAQ.md#-why-aidd-instead-of
## ✅ Prerequisites
-- **An AI coding tool** — Claude Code (native), or Cursor / Copilot / Codex / OpenCode (see [Compatibility](#-compatibility)).
+- **An AI coding tool** — Claude Code (native), or Cursor / Copilot / Codex / OpenCode / Kilo Code (see [Compatibility](#-compatibility)).
- **[Node](https://nodejs.org) 22 or later** on your `PATH`, only for the plugin that ships hooks ([what they do](docs/ARCHITECTURE.md#-bundled-hooks)); the workflows themselves are markdown and need nothing.
## 🔌 Compatibility
@@ -49,6 +49,7 @@ Why not just write your own commands? → [FAQ](docs/FAQ.md#-why-aidd-instead-of
| **GitHub Copilot** | ✅ Supported | Marketplace · Flat |
| **Codex** | ✅ Supported | Marketplace · Flat |
| **OpenCode** | ✅ Supported | Flat |
+| **Kilo Code** | ✅ Supported | Flat |
| **Gemini · Mistral** | 🚧 In progress | — |
**Marketplace** = installed and updated through your tool's plugin manager. **Flat** = files copied directly into your project, no plugin manager involved. Install steps per tool → [Other tools](#other-tools).
@@ -57,7 +58,7 @@ Why not just write your own commands? → [FAQ](docs/FAQ.md#-why-aidd-instead-of
### Claude Code
-Installs the 6 stable plugins (`aidd-ui` is 🚧 alpha and `aidd-telemetry` 🧪 beta, install separately — see [Plugins](#-plugins)).
+Installs the 6 stable plugins (`aidd-ui` is 🚧 alpha, `aidd-telemetry` 🧪 beta, and `aidd-qa` 🆕 new, install those separately — see [Plugins](#-plugins)).
**In the session** (slash commands)
@@ -174,6 +175,16 @@ codex plugin add aidd-context@aidd-framework # per plugin
+
+Kilo Code — Flat only
+
+1. Unzip the `kilo-flat` archive into your project root → `.kilo/`, including `.kilo/kilo.jsonc`.
+2. Start a new Kilo session so it loads the generated project plugin.
+
+[Plugins documentation](https://kilo.ai/docs/automate/extending/plugins)
+
+
+
## 🚀 Quick start
Three ways in — pick one:
@@ -229,7 +240,7 @@ learning only when it is durable enough to improve the next feature.
## 🧩 Plugins
-Eight plugins covering the whole SDLC — **install all of them**; they work together. (`aidd-ui` is 🚧 **alpha** and `aidd-telemetry` 🧪 **beta** — both off the curated path.)
+Nine plugins covering the whole SDLC — **install the six stable ones**; they work together. (`aidd-ui` is 🚧 **alpha**, `aidd-telemetry` 🧪 **beta**, and `aidd-qa` 🆕 **new** — all three off the curated path.)
@@ -246,9 +257,9 @@ Project init, memory bank, context-artifact generation, diagrams, learning, expl
### ⚙️ [aidd-dev](plugins/aidd-dev/README.md)
-`11 skills` · stable
+`10 skills` · stable
-Code transformation: plan, implement, assert, audit, review, test, refactor, debug. Standalone Browser QA records short web evidence.
+Code transformation: plan, implement, assert, audit, review, test, refactor, debug.
|
@@ -275,9 +286,9 @@ Three Amigos refinement, Product Briefs, Epics, User Stories, Tasks, Spikes, Def
### 🪞 [aidd-refine](plugins/aidd-refine/README.md)
-`4 skills` · stable
+`5 skills` · stable
-Brainstorm, challenge, shadow-areas, fact-check.
+Brainstorm, challenge, shadow-areas, fact-check, improve.
|
@@ -309,7 +320,15 @@ UI / UX design — smoke-test only, not ready for use.
Answers what a piece of work cost — tokens, models, and which skill spent them. The switch is git-tracked, so it applies to everyone who clones; opt out per person with `AIDD_TELEMETRY=0`. Nothing leaves your machine.
|
- |
+
+
+### 🎬 [aidd-qa](plugins/aidd-qa/README.md) 🆕
+
+`1 skill` · **new**
+
+Acceptance QA — locks browser scenarios from acceptance criteria and records reviewer evidence.
+
+ |
diff --git a/RELEASE.md b/RELEASE.md
index f3ab590a8..058eeefe8 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -41,6 +41,7 @@ flowchart LR
## 🚑 Hotfix
-1. Branch `hotfix/*` from `main`, fix, PR back to `main`.
-2. release-please cuts a dedicated patch release.
-3. `main` is back-merged into `next` automatically.
+1. Branch `hotfix/*` from `main`, fix, PR back to `main`. `.github/rulesets/main.json` declares merge as the only method the PR may use, so write every commit on the branch as conventional on its own — none gets squashed away.
+2. Merge with an empty description — clear the box in the UI, or run `gh pr merge --merge --body ""` — because this repository's merge commits default their body to the PR title, and release-please reads a non-empty body as a second, duplicate commit in the next release's notes.
+3. release-please cuts a dedicated patch release.
+4. `main` is back-merged into `next` automatically.
diff --git a/aidd_docs/memory/architecture.md b/aidd_docs/memory/architecture.md
index 7390570bf..7ba45d316 100644
--- a/aidd_docs/memory/architecture.md
+++ b/aidd_docs/memory/architecture.md
@@ -10,13 +10,13 @@ The macro technical shape: the stack, how the pieces fit, and the decisions behi
| --- | --- |
| Product | markdown — skills, agents, rules, templates. No framework runtime; an LLM interprets them. |
| Delivery | Node `>=22.12`, pnpm. `cli/` is the `aidd` binary; `kanban/` is a private package. |
-| Manifest | `.claude-plugin/marketplace.json`, the plugin manifest: 8 plugins, no version among them. Versions are release-please's, in `deployment.md`. |
+| Manifest | `.claude-plugin/marketplace.json`, the plugin manifest: 9 plugins, no version among them. Versions are release-please's, in `deployment.md`. |
## How it fits together
```mermaid
flowchart LR
- Manifest[".claude-plugin/marketplace.json"] -->|lists| Plugins["plugins/ · 8"]
+ Manifest[".claude-plugin/marketplace.json"] -->|lists| Plugins["plugins/ · 9"]
Plugins -->|ships| Surfaces["skills · agents · commands · hooks · rules"]
CLI["cli/ · aidd"] -->|reads| Manifest
CLI -->|installs| Target["a project's AI tool dir"]
@@ -40,6 +40,6 @@ The concern-to-plugin taxonomy is canonical in [`docs/ARCHITECTURE.md`](../../do
## Gotchas
-- 8 plugins ship, 2 off the curated install path: `aidd-ui` is alpha, `aidd-telemetry` beta and opt-in.
+- 9 plugins ship, 3 off the curated install path: `aidd-ui` is alpha, `aidd-telemetry` beta and opt-in, `aidd-qa` new and unproven outside this repository.
- A skill never links outside itself: the tree ships both flat and as a marketplace, so no relative path survives both.
- Bundled hooks run Node. No `node` on `PATH`, no memory refresh and no run journal.
diff --git a/aidd_docs/memory/coding-assertions.md b/aidd_docs/memory/coding-assertions.md
index 886a4fe62..f68a25294 100644
--- a/aidd_docs/memory/coding-assertions.md
+++ b/aidd_docs/memory/coding-assertions.md
@@ -26,7 +26,7 @@ Never state in a commit message or a report anything not just observed in output
| Order | Command | Checks |
| ----- | ------- | ------ |
-| 1 | `pnpm exec lefthook run pre-commit` | JSON and YAML validity, `scripts/` tests, skill frontmatter and argument hints, context imports and reference form, markdown links, the paths the prose names and a sentence written in two documents (`scripts/check-doc-duplication.js`); `cli` lint, architecture, typecheck and type honesty when `cli/` changed. `cli` knip and the full `cli` suite are pre-push, not pre-commit — see below |
+| 1 | `pnpm exec lefthook run pre-commit` | JSON and YAML validity, `scripts/` tests, skill frontmatter and argument hints, cross-plugin orthogonality and router coherence (`scripts/check-architecture-rules.js`), context imports and reference form, markdown links, the paths the prose names and a sentence written in two documents (`scripts/check-doc-duplication.js`); `cli` lint, architecture, typecheck and type honesty when `cli/` changed. `cli` knip and the full `cli` suite are pre-push, not pre-commit — see below |
| 2 | `pnpm exec commitlint --edit` | the message against `commitlint.config.cjs` |
Same hook regenerates each plugin's `CATALOG.md`, the README counts and `docs/prompts-documentation.md`, and stages them.
@@ -43,7 +43,13 @@ Every `cli` job is globbed on `cli/**`. A change under `kanban/` alone fires no
| ----- | ------- | ------ |
| 1 | `pnpm exec lefthook run pre-push` | `cli knip`, then the full `cli` suite, when `cli/` changed |
-`--no-verify` buys nothing: `validate.yml` re-runs the whole pre-commit over the whole tree on every push and pull request.
+Each runs through `scripts/gate-witness.js`, which skips a gate this exact tree already passed: same index, same unstaged edits, same untracked files. A tree that changed while the gate ran is never stamped.
+
+`--no-verify` buys little, not nothing:
+
+- A push to a feature branch fires no workflow.
+- Any pull request fires `ci.yml` and `cli-ci.yml`, whatever its base.
+- Only a pull request targeting `main` or `next` replays the pre-commit, and `validate.yml` drops `cli-biome`, `cli-architecture` and `cli-typecheck` from it.
## Behavior
diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md
index c7a486b95..901f9cdd9 100644
--- a/aidd_docs/memory/deployment.md
+++ b/aidd_docs/memory/deployment.md
@@ -9,7 +9,7 @@ Where the project runs and how it ships: CI/CD, environments, and release.
| Workflow | Runs |
| --- | --- |
| `ci.yml` | commitlint on pull requests and on `main`'s tip, plus the PR title itself — the subject a squash merge uses — then release-please on `main` and the release jobs |
-| `cli-ci.yml` | the `cli` and `kanban` gates — job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do — `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself, and `plugins/aidd-telemetry/**` except its `*.md` prose |
+| `cli-ci.yml` | the `cli` and `kanban` gates — job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do — `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself, and `plugins/aidd-telemetry/**` except its `*.md` prose. Mutations skip only for a same-repository numeric `promote/next-to-main-*` snapshot whose `cli / gate` passed in a successful `next` push and whose PR merge tree equals that snapshot with `main` already its ancestor; the resulting `main` push reuses it only for that exact two-parent promotion merge when its tree, associated merged PR, and source snapshot all match. Missing, failed, unreadable, or mismatched Git/API proof keeps normal mutation scopes. All non-mutation checks still run on the current PR merge ref or `main` commit. |
| `validate.yml` | plugin and marketplace manifests against their schemas, plus the whole pre-commit over the whole tree |
| `codeql.yml` | code scanning |
| `promote.yml` | opens the `next` to `main` promote PR, merge auto-merge |
@@ -44,13 +44,13 @@ None — no server, no container, no IaC. What ships are release assets and publ
Branch model in `vcs.md`, cadence and safety rules in [`RELEASE.md`](../../RELEASE.md).
-1. release-please opens the Release PR. Only paths with commits bump; the root bumps every cycle. CI auto-merges it with `--squash --admin`, because the branch policy refuses a plain merge, so `main` never holds merged but unversioned code.
-2. Merging creates the release and its tags — a root umbrella tag, `cli-v`, and one `-v` per plugin, `include-component-in-tag: true`.
+1. release-please opens the Release PR. Only paths with commits bump; the root bumps every cycle. CI auto-merges it with `--merge --admin`, the only method `.github/rulesets/main.json`'s `pull_request` rule allows, so `main` never holds merged but unversioned code.
+2. Merging creates the release and its tags — a root umbrella tag, `cli-v`, and one `-v` per plugin, `include-component-in-tag: true`. `scripts/credit-release-authors.cjs` then appends each line's commit author as `(@login)`: a workaround until [googleapis/release-please#2892](https://github.com/googleapis/release-please/pull/2892) ships.
3. Release jobs: `build-and-attach` (marketplace bundle), `build-per-tool` (nine distributions), `build-plugin` (one archive per released path), `publish-cli`.
4. Archives are staged outside the repo tree, uploaded with `gh release upload --clobber`.
5. `back-merge.yml` folds `main` into `next`.
-Config: `release-please-config.json`, ten packages. Manifest: `.release-please-manifest.json`.
+Config: `release-please-config.json`, eleven packages. Manifest: `.release-please-manifest.json`.
## Gotchas
diff --git a/aidd_docs/memory/project-brief.md b/aidd_docs/memory/project-brief.md
index 781087fc7..80099b5b7 100644
--- a/aidd_docs/memory/project-brief.md
+++ b/aidd_docs/memory/project-brief.md
@@ -40,6 +40,7 @@ What this project is, the problem it solves, and its domain language. The non-de
| Generate context artifacts | `aidd-context:03-context-generate` and its per-kind generators |
| Development loop | `aidd-dev` — plan, implement, assert, audit, review, test, refactor, debug |
| Typed product backlog | `aidd-pm` — brief, epic, story, spec, spike, defect |
+| Acceptance QA evidence | `aidd-qa:01-acceptance-qa` |
| Refine input and output | `aidd-refine` — brainstorm, challenge, blind spots |
| End-to-end orchestration | `aidd-orchestrator:01-sdlc` |
| Measure what a session cost | `aidd-telemetry`, opt-in, plus `aidd telemetry` |
diff --git a/aidd_docs/memory/testing.md b/aidd_docs/memory/testing.md
index 39acfa3e6..eb1b17575 100644
--- a/aidd_docs/memory/testing.md
+++ b/aidd_docs/memory/testing.md
@@ -13,7 +13,7 @@ How the project is tested: the layers, the tools, and the conventions. Where tes
| `cli/` | vitest, four projects — see the CLI bank |
| `kanban/` | its own vitest suite. It shares no code with `cli/` |
| Per-tool distributions | golden snapshots in `cli/tests/golden/`, mirrored by the `build-per-tool` CI matrix; Claude Code's own `plugin validate` over a fresh claude build, in `cli-ci.yml` |
-| Browser journeys | `aidd-dev:11-browser-qa`, see below |
+| Browser journeys | `aidd-qa:01-acceptance-qa`, see below |
## Tools
@@ -46,4 +46,4 @@ CI runs more: `validate.yml` re-runs the whole pre-commit over the whole tree on
- Runner: `npx --yes @playwright/cli@0.1.17`, the framework pin. Never `latest` during QA.
- Also required: `ffmpeg` and `ffprobe`. Output is WebM evidence per scenario.
-- Owned by `aidd-dev:11-browser-qa`; this repository ships the capability, it has no browser journey of its own.
+- Owned by `aidd-qa:01-acceptance-qa`; this repository ships the capability, it has no browser journey of its own.
diff --git a/aidd_docs/memory/vcs.md b/aidd_docs/memory/vcs.md
index 0b2387385..2f43a52a0 100644
--- a/aidd_docs/memory/vcs.md
+++ b/aidd_docs/memory/vcs.md
@@ -32,6 +32,11 @@ The version-control conventions this project follows: branches, commits, and the
- The board does not advance on its own; it is moved by hand, by a human or an agent through `gh`. Board conventions are in `backlog.md`.
- Automation owns `promote/*` and `back-merge/*`, which follow neither the format nor the table.
+## Pull requests
+
+- A pull request stacked on another targets that branch, not `next`. Before merging the base, retarget the dependent one: `gh pr edit --base next`. GitHub closes a pull request whose base branch is deleted, unless the deletion is the merge's own, so never delete a branch another open pull request targets.
+- A squash merge of the base leaves the dependent branch carrying the base's original commits, and it conflicts with `next`. Replay only its own commits: `git rebase --onto origin/next `, then push with `--force-with-lease`.
+
## Commits
- Convention: [Conventional Commits](https://www.conventionalcommits.org/), enforced by `commitlint.config.cjs`. **Read that file before composing a message; if this page and the config disagree, the config wins.**
diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-1.md
new file mode 100644
index 000000000..b3c42f37d
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-1.md
@@ -0,0 +1,68 @@
+---
+status: done
+---
+
+# Instruction: Reuse a validated promotion snapshot
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+.
+└── .github/
+ └── workflows/
+ └── cli-ci.yml ✏️ classify promotion snapshots, verify a prior next gate, and skip only the duplicate mutation matrix
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[system: bot opens promote/next-to-main snapshot PR] --> B{same SHA has successful push gate on next?}
+ B -- yes --> C[run normal merge-ref checks without mutations]
+ C --> D[cli / gate passes]
+ B -- no --> E[run normal CLI and selected mutation jobs]
+ E --> D
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ promotion snapshot with a successful next gate => workflow receives its head SHA: 5: system
+ section Happy path
+ trigger promotion PR => mutations skip while normal checks validate the merge ref: 5: system
+ section Edge case - missing proof
+ no successful next gate for the SHA => normal CLI and mutation jobs remain required: 5: system
+```
+
+## Tasks to do
+
+### `1)` Classify a trusted promotion snapshot
+
+> Prove the snapshot was already gated on `next`, fail closed otherwise.
+
+1. Add the least privilege needed to read workflow runs.
+2. Query successful `push` runs on `next` for the exact PR head SHA and require the `cli / gate` job to have succeeded.
+3. Expose an empty mutation scope list only after that proof; preserve normal scope selection otherwise.
+
+### `2)` Keep merge integration coverage
+
+> Remove only repeated source mutation testing from the PR merge ref.
+
+1. Skip only `cli-mutation` for a trusted promotion snapshot.
+2. Preserve all existing non-mutation jobs and their gate wiring.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | Only a `promote/next-to-main-` PR whose exact head SHA already has a successful `push` `cli / gate` on `next` may set mutation scopes to empty. |
+| 1 | Missing, failed, or unreadable validation proof does not skip mutations. |
+| 2 | A trusted promotion still runs coverage, smoke, build, platform, and other non-mutation checks against GitHub's PR merge ref. |
+| 2 | Ordinary pull requests retain their existing job and mutation behavior. |
diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-2.md
new file mode 100644
index 000000000..e015bb948
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-2.md
@@ -0,0 +1,66 @@
+---
+status: done
+---
+
+# Instruction: Lock the workflow contract
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+.
+├── scripts/
+│ └── __tests__/
+│ └── cli-ci-gate-covers-every-job.test.js ✏️ assert promotion reuse and required-gate coverage
+└── aidd_docs/
+ └── memory/
+ └── deployment.md ✏️ describe promotion-specific mutation reuse
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[system: workflow configuration changes] --> B[static workflow contract test]
+ B --> C{promotion safety and gate fan-in hold?}
+ C -- yes --> D[CI configuration is valid]
+ C -- no --> E[descriptive test failure]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ CI workflow YAML and its contract test => workflow parsed: 5: system
+ section Happy path
+ run the workflow contract test => trusted promotion, fallback, retained merge checks, and gate wiring are asserted: 5: system
+ section Edge case - future job
+ add an ungated job => gate-coverage test fails: 5: system
+```
+
+## Tasks to do
+
+### `1)` Assert promotion safety structurally
+
+> Make regressions in the promotion fast path visible before merge.
+
+1. Extend the existing workflow contract test with the trusted-promotion and fallback invariants.
+2. Assert a trusted promotion empties only mutation scopes and retains the existing gate fan-in.
+
+### `2)` Record the operating model
+
+> Keep deployment memory aligned with the workflow.
+
+1. Replace the generic CLI CI description with its promotion reuse rule and fail-closed fallback.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | The contract test fails if trusted promotion detection, mutation fallback, retained checks, or gate fan-in is removed. |
+| 2 | Deployment memory accurately states when promotion skips mutations and what still runs. |
diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-3.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-3.md
new file mode 100644
index 000000000..d85d6e8e5
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-3.md
@@ -0,0 +1,69 @@
+---
+status: done
+---
+
+# Instruction: Bind promotion reuse to the tested merge tree
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+.
+├── .github/
+│ └── workflows/
+│ └── cli-ci.yml ✏️ fail closed when main is not already contained by the promotion snapshot
+└── scripts/
+ └── __tests__/
+ └── cli-ci-gate-covers-every-job.test.js ✏️ lock the ancestry requirement
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[promotion snapshot PR to main] --> B{main base is ancestor of snapshot?}
+ B -- yes, next gate passed --> C[skip duplicate mutations]
+ B -- no --> D[run selected mutation scopes]
+ C --> E[run all merge-ref checks]
+ D --> E
+```
+
+## Test Scope
+
+```mermaid
+flowchart TD
+ A[workflow contract] --> B{ancestry guard present before empty scopes?}
+ B -- yes --> C[contract passes]
+ B -- no --> D[contract fails]
+```
+
+## Wireframe
+
+```txt
+No UI: GitHub Actions workflow behavior only.
+```
+
+## Tasks to do
+
+### `1)` Prove promotion content is unchanged
+
+> Reuse a `next` mutation result only when the PR merge cannot add untested main content.
+
+1. Read the promotion PR base and snapshot SHAs from the event.
+2. Require the base SHA to be an ancestor of the snapshot before marking a promotion trusted.
+3. Keep every failed or unreadable Git proof on the normal mutation path.
+
+### `2)` Lock the fail-closed guard
+
+> Make a future removal of the ancestry check fail locally.
+
+1. Extend the workflow contract test with the base-to-snapshot proof and fallback expectation.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | A promotion with uncontained `main` content cannot set `mutation_scopes=[]`. |
+| 1 | A promotion whose base is contained by its exact snapshot and whose `next` gate passed retains the mutation skip. |
+| 2 | The contract test fails when the ancestry proof or fail-closed fallback is removed. |
diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-4.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-4.md
new file mode 100644
index 000000000..7eeb9eea7
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/phase-4.md
@@ -0,0 +1,76 @@
+---
+status: done
+---
+
+# Instruction: Reuse the proven promotion merge on main
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+.
+├── .github/
+│ └── workflows/
+│ └── cli-ci.yml ✏️ recognize a proven promotion merge on main and skip only duplicate mutations
+├── scripts/
+│ └── __tests__/
+│ └── cli-ci-gate-covers-every-job.test.js ✏️ lock main-merge proof and unchanged fallback
+└── aidd_docs/
+ └── memory/
+ └── deployment.md ✏️ document both trusted reuse paths
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[push merge commit to main] --> B{matching promotion PR?}
+ B -- no --> C[run selected mutations]
+ B -- yes --> D{merge tree equals proven snapshot and next gate passed?}
+ D -- no --> C
+ D -- yes --> E[skip duplicate mutations]
+ C --> F[run normal non-mutation checks]
+ E --> F
+```
+
+## Test Scope
+
+```mermaid
+flowchart TD
+ A[workflow contract] --> B{promotion PR identity, tree equality, and next gate required?}
+ B -- yes --> C[contract passes]
+ B -- no --> D[contract fails]
+```
+
+## Wireframe
+
+```txt
+No UI: GitHub Actions workflow behavior only.
+```
+
+## Tasks to do
+
+### `1)` Classify a trusted main promotion merge
+
+> Skip mutations on main only for the exact content already gated on next.
+
+1. Identify the merged promotion PR and its snapshot with read-only repository data.
+2. Require the final main commit tree to equal that snapshot's tree.
+3. Reuse only the successful `cli / gate` run for the snapshot's exact SHA on `next`.
+
+### `2)` Preserve normal behavior and explain it
+
+> Keep every ambiguous, failed, or unrelated main push fully protected.
+
+1. Route missing PR identity, unavailable API data, mismatched trees, and failed gates to normal mutation scope selection.
+2. Assert the trusted-main contract structurally and update deployment memory.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | Only a merge from the numbered promotion branch whose final tree equals the snapshot may skip mutations on `main`. |
+| 1 | The matching snapshot must have a successful `push` `cli / gate` on `next`. |
+| 2 | Every missing, unreadable, mismatched, or unrelated proof preserves normal mutation execution. |
+| 2 | Non-mutation jobs and gate fan-in remain unchanged for all events. |
diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md
new file mode 100644
index 000000000..ffc8c9e04
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/plan.md
@@ -0,0 +1,39 @@
+---
+objective: "A next-to-main promotion and its resulting main merge reuse an already-passing mutation gate only when the exact tested tree is proven unchanged."
+status: implemented
+---
+
+# Plan: Reuse validated next CI through promotion
+
+## Overview
+
+| Field | Value |
+| --- | --- |
+| **Goal** | Remove duplicate mutation matrices from promotion and its resulting `main` merge without weakening any gate. |
+| **Source** | User request: strict SDLC after the CI-wide challenge. |
+
+## Phases
+
+| # | Phase | File |
+| --- | --- | --- |
+| 1 | Reuse a validated promotion snapshot | [`phase-1.md`](./phase-1.md) |
+| 2 | Lock the workflow contract | [`phase-2.md`](./phase-2.md) |
+| 3 | Bind promotion reuse to the tested merge tree | [`phase-3.md`](./phase-3.md) |
+| 4 | Reuse the proven promotion merge on `main` | [`phase-4.md`](./phase-4.md) |
+
+## Resources
+
+| Source | Verified |
+| --- | --- |
+| https://docs.github.com/en/rest/actions/workflow-runs | Workflow runs can be filtered by branch, event, and head SHA with Actions read permission. |
+| https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows | A pull-request checkout uses the synthetic merge ref, so a promotion-specific smoke can validate the merged result. |
+| Repository evidence: PR #809 | Its merge tree equalled the promoted `next` snapshot, but that invariant was convention-only rather than enforced. |
+
+## Decisions
+
+| Decision | Why |
+| --- | --- |
+| Reuse only a successful `push` run for the exact snapshot SHA on `next`. | The promotion source has already satisfied `next`'s required gate; absent or unsuccessful proof must not bypass mutations. |
+| Keep all non-mutation jobs on the promotion PR merge ref. | Coverage, smoke, build, and platform checks still validate the merge of release metadata from `main`. |
+| Require the promotion PR base to be an ancestor of its snapshot before reuse. | A previously green `next` run is insufficient if `main` contributes untested content to the PR merge tree. |
+| On a `main` push, require both the matching promotion PR and tree equality with its snapshot. | A matching source SHA alone cannot prove that the merge commit carries the same content. |
diff --git a/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md
new file mode 100644
index 000000000..52d18a63d
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_09_promotion-ci-reuse/review.md
@@ -0,0 +1,49 @@
+# Review: Reuse validated next CI through promotion
+
+- **Verdict**: approve
+- **Diff**: `origin/next...92941865`
+- **Axes run**: code, functional, relevancy
+- **Date**: 2026_09_10
+- **Findings**: 0 critical, 0 warning, 0 minor
+
+## Phases
+
+### Phase 1 — Reuse a validated promotion snapshot
+
+- [x] Only a same-repository `promote/next-to-main-` PR whose exact head SHA has a successful `push` `cli / gate` on `next` may set mutation scopes to empty. — `.github/workflows/cli-ci.yml:110-145,185-202`
+- [x] Missing, failed, or unreadable validation proof does not skip mutations. — `.github/workflows/cli-ci.yml:107-131,136-145,185-202`
+- [x] A trusted promotion still runs coverage, smoke, build, platform, and other non-mutation checks against GitHub's PR merge ref. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:120-140`
+- [x] Ordinary pull requests retain their existing job and mutation behavior. — `.github/workflows/cli-ci.yml:136-145,185-202`
+
+### Phase 2 — Lock the workflow contract
+
+- [x] The contract test fails if trusted promotion detection, mutation fallback, retained checks, or gate fan-in is removed. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:63-140`
+- [x] Deployment memory accurately states when promotion skips mutations and what still runs. — `aidd_docs/memory/deployment.md:12`
+
+### Phase 3 — Bind promotion reuse to the tested merge tree
+
+- [x] A promotion with uncontained `main` content cannot set `mutation_scopes=[]`. — `.github/workflows/cli-ci.yml:136-145,185-202`
+- [x] A promotion whose base is contained by its exact snapshot and whose `next` gate passed retains the mutation skip. — `.github/workflows/cli-ci.yml:136-145`
+- [x] The contract test fails when the ancestry proof or fail-closed fallback is removed. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:84,115-117`
+
+### Phase 4 — Reuse the proven promotion merge on `main`
+
+- [x] Only a two-parent merge from the numbered, same-repository promotion branch whose final tree equals the snapshot may skip mutations on `main`. — `.github/workflows/cli-ci.yml:149-175,185-202`
+- [x] The matching snapshot must have a successful `push` `cli / gate` on `next`. — `.github/workflows/cli-ci.yml:110-131,170-172`
+- [x] Every missing, unreadable, mismatched, or unrelated proof preserves normal mutation execution. — `.github/workflows/cli-ci.yml:107-108,149-175,185-202`
+- [x] Non-mutation jobs and gate fan-in remain unchanged for all events. — `scripts/__tests__/cli-ci-gate-covers-every-job.test.js:12-27,120-140`
+
+## Findings
+
+| Sev | Kind | Phase | Location | Issue | Fix |
+| --- | --- | --- | --- | --- | --- |
+| — | — | — | — | None. | — |
+
+## Verification
+
+| Metric | Value |
+| --- | --- |
+| Verified | 100% (13/13) |
+| Files checked | `.github/workflows/cli-ci.yml`, `scripts/__tests__/cli-ci-gate-covers-every-job.test.js`, `aidd_docs/memory/deployment.md`, `.github/workflows/promote.yml`, `.github/rulesets/main.json`, `.github/rulesets/next.json`, `phase-1.md`, `phase-2.md`, `phase-3.md`, `phase-4.md` |
+| Unchecked | none |
+| Unplanned | none |
diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/challenge.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/challenge.md
new file mode 100644
index 000000000..bd4a19f6a
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/challenge.md
@@ -0,0 +1,17 @@
+My confidence level of correctness now: 68%
+
+# Correctness (100%)
+
+- The A/B ownership and foreign-state behavior is backed by 6,464 passing tests, byte-for-byte collision smoke, and an independent checker with no remaining confirmed security blocker.
+- The core #829 behavior is supported, but the strict delivery claim is not correct yet: `framework` mutation scored 86.7% against its declared 93% floor, while 13 final-tree scopes remain unchecked. The user authorized a partial draft, not a green gate or merge.
+- Copilot 1.0.83 fails closed on native source proof. That preserves existing state but does not deliver fresh native activation on that installed version.
+
+# Deal breakers
+
+- Do not mark the issue closed or the draft PR merge-ready under strict SDLC. At least 515 additional framework mutants must be detected to meet the existing floor; targeted witnesses or a simpler proof flow need a new validated candidate.
+- Partial Copilot 1.0.83 functionality must remain explicit in the draft. Native fresh activation is not verified, so end-to-end satisfaction remains uncertain.
+
+# Suggestions (enhancements only)
+
+- Inventory changed-line mutants before the expensive whole-scope campaign, then test each negative and positive ownership branch; retain the whole-scope gate for the final tree.
+- Keep a single source/test SHA and Node/HOME fixture contract through full gates and mutation. Reuse a green report only when those inputs are identical.
diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/frame.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/frame.md
new file mode 100644
index 000000000..8da930c2c
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/frame.md
@@ -0,0 +1,11 @@
+# #829 Check follow-up contract
+
+Source: [issue #829](https://github.com/ai-driven-dev/framework/issues/829), `review.md`, and the second read-only preflight (blocked, 2026-09-15). The user will handle legacy state manually.
+
+1. Project sync never refreshes a host marketplace without proving the exact host catalogue is AIDD-owned and contains no foreign plugin refs. No unnamed/global host refresh. A project-labelled catalogue is not proven by a plugin claim alone; without canonical catalogue provenance, skip refresh, preserve host state, and name the limitation. Fresh install must still activate AIDD's plugin without taking over a preexisting catalogue.
+2. A conflicting reserved `aidd-framework` host catalogue is never force-removed or rebuilt without canonical AIDD ownership and absence of foreign refs, even when the host calls its registration dead or unknown. Otherwise refuse and leave host state intact.
+3. Project `marketplace remove` and `uninstall plugin` remove only A's project-local hooks/scripts and any tool-specific local MCP projection created by that flow before deleting A's manifest projection or detaching A's machine claim where one exists. Cursor's MCP file is user-global, not project-local, and stays for B. Failure retains the truthful local projection and any existing machine claim for a safe retry; B and all machine-owned files/refs remain intact. Do not invent an OpenCode machine claim.
+4. A prior AIDD hostName/ref claim is not proof of the current host catalogue source. Native sync, targeted update/remove, and project/user clean compare the effective source read from the host with exact AIDD provenance before any host mutation. Even an owned catalogue may contain a foreign installed ref: catalogue removal also requires the full host ref list and refuses a noncanonical ref. Fresh absence is read from the host; an unreadable/unsupported source fails closed. The installed Copilot 1.0.83 lacks documented structured listing, so it must not activate partially or fabricate ownership.
+5. A name/path is not proof of current local file contents. Use installed digests for Cursor user-scope files, OpenCode MCP entries, and Cursor project hook entries/scripts before update/unmerge/delete; a changed or legacy-unproven contribution remains in place and retains truthful claims for manual reconciliation.
+
+Proof: test-first A/B and foreign fake-HOME host assertions, targeted mutation of each destructive guard, then all required CLI gates and a fresh independent Check. No automated legacy migration, no floor reductions, no force bypass.
diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-1.md
new file mode 100644
index 000000000..687669de8
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-1.md
@@ -0,0 +1,64 @@
+---
+status: in-progress
+---
+
+# Instruction: guard native activation
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+cli/
+ src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts ✏️ provenance before upgrade/reclaim
+ src/contexts/tools/domain/ports/native-plugin-activator.ts ✏️ exact catalogue refresh contract if supported
+ src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts ✏️ exact host CLI dispatch if supported
+ tests/contexts/framework/application/flows/ ✏️ foreign collision and A/B activation
+ tests/contexts/tools/infrastructure/ ✏️ exact CLI process arguments
+ scripts/smoke-collision.sh ✏️ catalogue and cache witnesses
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ Fixture["Foreign catalogue and plugin in fake HOME"] --> Sync["A syncs its plugin"]
+ Sync --> Choice{"AIDD proves catalogue ownership?"}
+ Choice -- "No" --> Refuse["Name collision; foreign state unchanged"]
+ Choice -- "Yes" --> Exact["Refresh exact AIDD catalogue only"]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Native activation test scope
+---
+journey
+ section Setup
+ Seed foreign catalogue and cache => witness hashes recorded: 5: cli
+ section Happy path
+ Sync AIDD owned exact catalogue => only that catalogue refreshes: 5: cli
+ section Edge case - foreign collision
+ Foreign same name or ref => sync refuses and hashes stay equal: 1: cli
+ section Edge case - stale registration
+ Unproven dead host registration => no force remove or rebuild: 1: cli
+ section Teardown
+ Clean fake HOME => real HOME unchanged: 5: cli
+```
+
+## Tasks to do
+
+### `1)` Prove ownership before host mutation
+
+> No unscoped refresh or forced takeover can run on foreign state.
+
+1. Write failing fake-host collision tests and targeted mutation checks.
+2. Limit refresh to exact proven host catalogues; refuse when the host cannot target safely.
+3. Guard reserved-name reclaim with canonical ownership and no foreign refs; update older migration expectations.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | A sync preserves foreign catalogue registration, plugin ref, and cache bytes; only a proven AIDD catalogue changes, with exact host arguments. |
diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-2.md
new file mode 100644
index 000000000..5acff6950
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-2.md
@@ -0,0 +1,64 @@
+---
+status: in-progress
+---
+
+# Instruction: finish project-local removal
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+cli/
+ src/contexts/framework/application/flows/marketplace-remove-use-case.ts ✏️ local cleanup before orphan detach
+ src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts ✏️ local cleanup before uninstall detach
+ src/contexts/framework/application/ownership/project-plugin-cleanup.ts ✏️ reuse or narrow shared cleanup
+ tests/contexts/framework/application/flows/ ✏️ Cursor hook and MCP orphan removal
+ tests/contexts/framework/application/ ✏️ uninstall failure and B survival
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ Shared["A and B share a machine plugin"] --> Remove["A removes a marketplace or uninstalls"]
+ Remove --> Local["Remove A's local hooks and any local MCP projection"]
+ Local --> Choice{"Local cleanup succeeded?"}
+ Choice -- "No" --> Retain["Keep truthful local projection and any existing claim"]
+ Choice -- "Yes" --> Detach["Detach A; B and machine plugin remain"]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Project removal test scope
+---
+journey
+ section Setup
+ Install shared Cursor plugin in A and B => local hook and global MCP witnesses ready: 5: cli
+ section Happy path
+ Remove from A => A integration gone and B still works: 5: cli
+ section Edge case - local failure
+ Hook or MCP delete fails => A projection and any claim stay for retry: 1: cli
+ section Edge case - tool-specific MCP
+ Remove OpenCode flat plugin => only A's local MCP entry unmerged: 1: cli
+ section Teardown
+ Remove B then user clean => machine plugin removed only after both detach: 5: cli
+```
+
+## Tasks to do
+
+### `1)` Complete local cleanup before detachment
+
+> Do not leave A's local projections behind without a plugin record; do not remove Cursor's global MCP.
+
+1. Write failing `marketplace remove` and `uninstall plugin` integration tests.
+2. Reuse project-local cleanup for hooks/MCP before manifest removal and claim detach.
+3. Prove failure retains A's claim and B's global plugin remains intact.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | Both commands remove A's project hooks/scripts and any local MCP projection created by that flow before detach; failure retains A's projection and any existing machine claim; Cursor's global MCP, B, and machine-owned state remain unchanged. |
diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-3.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-3.md
new file mode 100644
index 000000000..6581179e9
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-3.md
@@ -0,0 +1,26 @@
+---
+status: in-progress
+---
+
+# Instruction: prove current host source
+
+## Outcome
+
+For Codex/Copilot, read the effective host catalogue source before add, refresh, update, project removal, or project/user clean. An AIDD machine claim alone cannot authorize a mutation when the source has been repointed. Before unregistering even an owned catalogue, prove its full host ref list contains no foreign ref. Fresh absence permits registration; an unreadable or foreign source refuses with a named manual remedy. Record exact source only after a proven fresh add. No legacy claim inference.
+
+Apply this gate to project plugin removal even when an old manifest lacks a native registration, if that removal would reach the host. Project clean may still detach a shared user-scope claim and clean local state without catalogue proof, because it must not mutate that shared catalogue. An unproven plugin ref is left enabled rather than uninstalled. [Copilot repository `enabledPlugins` is declarative auto-install](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference), so even writing settings is an activation side effect: defer that projection until the catalogue and plugin ref are proven and activated.
+
+Before `clean --scope user`, map every canonical machine claim to exactly one registered host catalogue with current source/ref proof. An orphan or ambiguous claim cannot be uninstalled through a different proven catalogue. A machine-global ref without a canonical claim is likewise not exclusive to this project, even if its host source is AIDD-owned.
+
+User clean preflights availability of every native activator it will need before uninstalling the first tool. A known missing second binary must not leave the first tool already changed while machine claims still record the old state.
+
+Codex 0.151.0 exposes `marketplace list --json` with `name`, `root`, and `marketplaceSource`. The installed Copilot 1.0.83 refuses `--json` despite current official docs; its repository overlay can replace the user source. Do not parse human text as an ownership proof. Feature-probe structured output and fail closed on unsupported versions until an exact source witness can be measured.
+
+## Verification
+
+- A/B fresh registration works; a foreign same-name source and cache stay byte-identical through sync/update/remove/clean.
+- Copilot activation refusal leaves foreign repository settings, including `extraKnownMarketplaces` and `enabledPlugins`, byte-identical.
+- Native CLI readers and source parsing use installed binary/official contracts, fail closed on unknown shapes.
+- User clean refuses an orphan `x@B` claim when only catalogue A is proven; project clean never uninstalls an unclaimed machine-global ref used manually by B.
+- Multi-tool user clean with the second binary unavailable refuses before the first host catalogue or ref changes.
+- Full tests, architecture, build, smoke, targeted destructive mutants, then all declared mutation scopes.
diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-4.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-4.md
new file mode 100644
index 000000000..ff31a14b0
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-4.md
@@ -0,0 +1,19 @@
+---
+status: in-progress
+---
+
+# Instruction: preserve edited local integration
+
+## Outcome
+
+OpenCode MCP and Cursor project hooks/scripts are user-owned after installation. Compare each current contribution with its recorded install digest before unmerge or deletion. Edited or legacy-unproven entries refuse local detach and preserve claims for manual reconciliation. Reinstall must treat edited MCP as a user collision. Do not delete an entire script directory by plugin name.
+
+Content digest is not path confinement: reject a hooks config, script/parent directory, or MCP output whose resolved path leaves canonical projectRoot, even if the external bytes match the install digest. Preflight before native/shared claims move, then revalidate immediately before write/delete.
+
+## Verification
+
+- A/B remove and clean preserve B and edited project integration; unedited A contributions are removed precisely.
+- A failed read/write or digest mismatch leaves A projection/claim truthful and retry-safe.
+- Project clean preflights edited hooks before dropping a shared-source reference or undoing any native registration; removal rechecks immediately before write.
+- Symlinked hooks config, script parent, and MCP output outside the project remain byte-identical; claims and B remain untouched.
+- Targeted RED→GREEN tests and destructive mutation witnesses; full gates run on integrated tree.
diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-5.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-5.md
new file mode 100644
index 000000000..e78cc03bc
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-5.md
@@ -0,0 +1,24 @@
+---
+status: in-progress
+---
+
+# Instruction: guard user-scope files
+
+## Outcome
+
+Before plugin update, remove, marketplace remove, or global user clean touches a Cursor user-scope file, compare current bytes with the installed digest already in the manifest. A changed, unreadable, or legacy-unproven file blocks that file's mutation and keeps ownership records truthful for manual reconciliation. Containment checks remain mandatory but are not content proof.
+
+On update, a new path absent from the previous owned-file list must also be absent on disk. An existing user-created path is not an AIDD file merely because a later plugin version wants to write it.
+
+For a plugin with both native refs and user files, prepare every file update and validate all old digests/new-path collisions before any native update. Applying a validated plan can still fail through unexpected I/O; retain truthful claims and name manual reconciliation rather than assert cross-host atomicity.
+
+Recheck all planned old paths just before the file write. If a tracked path or parent becomes a symlink outside the user plugin root between planning and application, refuse rather than writing through it; a reduced safe-file map is not permission to continue.
+
+## Verification
+
+- Edited `plugin.json`/script octets survive all four operations; an unedited A contribution is still removable without harming B.
+- A user-created `new.md` that collides with a new plugin-version file survives update byte-identically.
+- A mixed native+file update collision causes no host call, no file change, and no claim change.
+- A symlink substituted after planning cannot overwrite external bytes, even if the native host update has already succeeded.
+- A partial failure does not detach a project or machine claim while its local state remains unproven.
+- Targeted tests and destructive mutant witnesses, then the integrated full gates.
diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/plan.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/plan.md
new file mode 100644
index 000000000..2f98eeb21
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/plan.md
@@ -0,0 +1,44 @@
+---
+objective: "Project A can install or remove a plugin without changing foreign machine state or stranding A's local integration while B remains dependent."
+status: in-progress
+---
+
+# Plan: #829 Check repairs
+
+## Overview
+
+| Field | Value |
+| --- | --- |
+| **Goal** | Prove native catalogue ownership before host mutation; complete local cleanup before claim detach. |
+| **Source** | [#829](https://github.com/ai-driven-dev/framework/issues/829), [`frame.md`](./frame.md), [`review.md`](./review.md) |
+
+## Phases
+
+| # | Phase | File |
+| --- | --- | --- |
+| 1 | Guard native activation | [`phase-1.md`](./phase-1.md) |
+| 2 | Finish project-local removal | [`phase-2.md`](./phase-2.md) |
+| 3 | Prove current host source | [`phase-3.md`](./phase-3.md) |
+| 4 | Preserve edited local integration | [`phase-4.md`](./phase-4.md) |
+| 5 | Guard user-scope files | [`phase-5.md`](./phase-5.md) |
+
+## Resources
+
+| Source | Verified |
+| --- | --- |
+| `codex plugin marketplace upgrade --help` | No name updates all configured Git marketplaces. |
+| `copilot plugin marketplace update --help` | No name updates all registered marketplaces. |
+| [`docs/ARCHITECTURE.md`](../../../../docs/ARCHITECTURE.md) | Host CLI calls stay behind the native activator port; application owns provenance. |
+
+## Decisions
+
+| Decision | Why |
+| --- | --- |
+| Refuse an unproven host catalogue; no `force` takeover | #829 requires preexisting user state untouched, including same-name collisions. |
+| No unproven native legacy migration | Local registry scope normalization can continue; ambiguous host state is for manual reconciliation. |
+| Skip automatic refresh of unproven project-labelled catalogues | A plugin claim does not prove machine catalogue ownership; preserve foreign state before optimizing refresh. |
+| Local cleanup before machine claim detach | A failure must leave a truthful claim so retry cannot harm B. |
+| A past machine claim is not current host source proof | Same-name catalogues can be repointed after AIDD registration; refuse host mutations without a current source witness. |
+| Current source proof is not full ref ownership | An AIDD-owned catalogue can contain foreign host refs; read and partition host refs before unregistering it. |
+| Compare local integration to install digests | Hooks and MCP in user-owned project files may be edited; never remove by name alone. |
+| Compare user-scope files to install digests | Path containment is not ownership of current contents; edited files must not be updated or deleted by explicit user-scope operations. |
diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/retrospective.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/retrospective.md
new file mode 100644
index 000000000..22916e893
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/retrospective.md
@@ -0,0 +1,51 @@
+# #829 delivery retrospective
+
+## Observation and limits
+
+This records the delivery after the request to implement #829 from `origin/next`. The original dirty checkout was not edited; implementation used `framework-829`. Measurements below come from 141 completed command logs dated 2026-09-15 13:58:50–19:07:39 UTC. That 5 h 08 m 49 s window is not the total task duration, and overlapping command durations must not be added to infer elapsed wall time. Final gate results and later work must be appended before closing this report.
+
+## Measured verification cost
+
+| Completed runs | Cumulative command time | Meaning |
+| --- | ---: | --- |
+| 30 Stryker campaigns | 45 m 02 s | 11 framework runs: 26 m 36 s; five changed-lines runs: 9 m 38 s; 14 other targeted runs: 8 m 48 s. |
+| 17 full Vitest passes, excluding Stryker dry-runs | 9 m 06 s | Mean 32.17 s. Ten green passes after the fourth delivery candidate: 5 m 17 s across different trees, not ten equivalent proofs. |
+| First framework mutation campaign | 6 m 44 s | Score 90.1%, below the required 93% floor. Ten subsequent framework relaunches consumed another 19 m 52 s before scores near 93.0–93.1%. |
+
+A later global Stryker attempt aborted at dry-run after 49 s and 4% progress. Reused mutants and static mutants did not make it a valid final report. The team stopped further campaigns until the source and test contract were stable.
+
+## Bottlenecks and their causes
+
+1. The initial destructive-effect matrix omitted native same-name collisions, current effective source changes, foreign host references under an AIDD-owned catalogue, edited project hooks/MCP entries, and edited user-scope files. The first independent checker and the next preflight found these after expensive green gates. A third preflight found `clean` overblocking a shared catalogue, project `plugin remove` still able to uninstall a repointed ref, a new user-created file still able to be overwritten on update, and [Copilot's repository `enabledPlugins` declarative auto-install](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference) possible before the CLI activation refusal. Earlier passing gates were real for their candidate, but not proof for the repaired candidate.
+2. The proof interface changed while old fixtures were being adapted. One change yielded 84 red flow tests; moving the `references.json` write until *proven* registration reopened four reference tests. The contract should have been frozen before bulk fixture work. Positive test doubles must express independently observed host state, not copy the project's claims into the host reader.
+3. The environment was not pinned end to end. Installed Homebrew Node 25 failed to start because `libsimdjson.30.dylib` was missing. Pinning only the parent Node executable was insufficient; spawned tools also needed a Node 24-first `PATH`. A smoke pass reported green while `md5` was missing and both fingerprints were empty. Replacing it with Node SHA-256 exposed a Bash 3.2 empty-array failure, then fixed it. Smoke now hashes path *and content*, rejects failed hash computation, and runs under the system Bash.
+4. Mutation reports were sometimes compared after edits or a changed mutant inventory. Stryker's runner reuse and static `HOME` test fixtures also produced false survivors. Dependency-injected home lookup and targeted single-runner tests corrected this, but they do not validate an old report on a new tree. Mutation output must be bound to an immutable source hash and fixed environment.
+5. Bundle growth was measurable rather than free: first candidate 681.8/682 KB, repaired source/local guards 703.8/710 KB, later path guards 714.2 KB. The final measured budget is 722 KB, 1.1% headroom. This is a reviewable cost of the added guards, not a reason to loosen another gate.
+6. The late checker pass still found an ordering hazard: project clean detached the shared source claim before local file cleanup completed. A later local error could leave the project installed but unclaimed, making user-scope clean appear safe. The Copilot 1.0.83 source listing cannot supply structured proof, so fresh native registration remains intentionally unavailable on that installed version. These are separate findings: the first requires a code repair and failure-injection test; the second requires an explicit functional caveat, not fabricated native source data.
+
+## Next delivery loop, without relaxing quality or determinism
+
+1. Before costly checks, inventory the **installed** host binaries and their documented structured outputs. Run a fake-home, byte-for-byte foreign-state matrix for `sync`, targeted update/remove, uninstall, project clean, and user clean. Include A/B sharing, current source, all host refs, modified hooks/MCP, existing new-path collisions, and indirect repository settings effects. Fail closed where Copilot 1.0.83 cannot prove its current source; do not invent a text parser or upgrade the user's binary.
+2. Freeze the ports, provenance schema, operation ordering, and reference-write point. Adapt legacy fixtures once, with explicit future verified-reader test doubles separate from installed-binary E2E checks. Run targeted red→green tests for each destructive branch and compare untouched paths and contents, not only exit codes.
+3. On a frozen tree, run full Vitest, coverage floors, architecture ratchets, typecheck, lint, duplicate/dead-code checks, build budget, multi-tool smoke and collision smoke. An independent read-only preflight must clear all destructive paths before mutation. A source edit invalidates the complete-tree result for that path; rerun the relevant gates before proceeding.
+4. Run targeted destructive mutants, then all 14 mutation scopes **exclusively** on the frozen source hash under the same Node 24-first `PATH`, `HOME`, and `CODEX_THREAD_ID` fixture contract. Preserve the existing mutation floors; never promote an aborted, stale, or wrong-hash report. A final independent checker inspects both behavior and evidence before delivery.
+
+No saved-time estimate is justified yet: the data measures repeated work, not the counterfactual runtime of the improved loop. The proposed gain is fewer invalidated expensive passes, not fewer acceptance gates.
+
+## Pre-remedy checkpoint, not a completed delivery
+
+The staged diff before the safe-remedy wording change had SHA-256 `e9cab56dbcb0f4a3a164a7e664620439e178d2de84f213c74f6bb8db111e63b6`. Full Vitest passed 521/521 suites and 6,464/6,464 tests in 35.44 s. Coverage passed the same 6,464 tests in 44.25 s: 98.92% lines, 96.51% branches, 99.68% functions. Architecture 131/131, typecheck, lint, knip, jscpd, build 714.3/722 KB, 33/33 command smoke, and foreign-state collision smoke all exited zero. The independent checker found no remaining confirmed security blocker in the implementation, but warned that Copilot 1.0.83 cannot prove a fresh native catalogue source and therefore refuses native activation even for a new profile. The wording change creates a new source SHA; these gates must not be presented as proofs of that newer tree until rerun.
+
+The `framework` mutation scope on that pre-remedy SHA completed in 6 m 49 s with **86.7%**, below its declared **93%** floor. Its report has 7,127 killed, 11 timed out, 895 survived, and 196 uncovered mutants: 8,229 scored mutants, 1,091 undetected. At least 515 more must be detected to reach 93%. Of the undetected mutants, 747 lie on lines changed from `origin/next`; 239 are in `marketplace-sync-settings-use-case.ts`, 108 in `plugin-remove-use-case.ts`, and 64 in `clean-use-case.ts`. The report is real for that earlier tree, not for the later wording-only SHA, and cannot be promoted to green by reusing a prior candidate's score. The remaining 13 scopes were not run after the first required floor failed. No final repairs had been committed or pushed and no draft PR had been opened at this checkpoint.
+
+The account-wide Codex weekly window read 80% used at the last check; no pre-task baseline exists, so attributing that percentage to this delivery would be false. There are no reset credits. This budget constraint does not change the mutation floor; it makes an explicit product decision necessary before any urgent partial delivery.
+
+## What the late failure changes in the proposed loop
+
+The early preflight should include a mutation-capacity estimate on **changed lines** before 14 complete scopes, not a substitute for them. A full-framework run should occur before polishing delivery documents, and its mutant inventory must be checked against added safety branches. This candidate added extensive fail-closed paths whose ordinary and collision tests verify outcome, yet often do not distinguish mutated guards. The remedy is focused negative and positive witness tests around each decision boundary, and possibly simpler control flow where branches duplicate a proof; not raising a threshold, mutating fewer files, or calling the code done on a below-floor score. We cannot quantify how long 515 additional detections would take from current measurements.
+
+## Authorized partial-draft checkpoint
+
+The user authorized a partial draft only if the original #829 preservation behavior is correct. A fresh independent checker compared the staged source with the issue's literal Cursor/Codex/Copilot collision and clean cases, reran 127 targeted tests on eight files, and found no remaining confirmed blocker to those preservation outcomes. This is not certification of the undetected mutants or of fresh Copilot 1.0.83 activation. The checker assigned 80/100 for a draft, not merge approval.
+
+After replacing an inherited destructive, Claude-specific conflict remedy with a host-neutral manual-reconciliation warning, the new source tree passed 521/521 suites and 6,464/6,464 tests in 37.05 s; coverage passed the same tests in 39.87 s at 98.92% lines, 96.51% branches and 99.68% functions. Build measured 714.3/722 KB, command smoke exercised 33/33 leaves with no failure, and foreign-state collision smoke had no failure. The earlier 86.7% framework mutation report does **not** measure this later wording-only tree. Under the explicit waiver, a draft PR may be prepared, but it must not be merged, #829 must not be closed, and the declared mutation floor remains 93% until a fresh campaign meets it.
diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/review.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/review.md
new file mode 100644
index 000000000..3fe5702e9
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/review.md
@@ -0,0 +1,89 @@
+# Review: #829 shared user-scope plugin safety
+
+- **Verdict**: blocked
+- **Diff**: `origin/next...codex/fix-829-shared-scope`; mutation hardening published in `58b5e92b`, CI corrections under verification
+- **Axes run**: code, functional, relevancy
+- **Date**: 2026_09_17
+- **Findings**: 1 critical, 2 warnings, 0 minor
+
+## Phases
+
+### Phase 1 — Guard native activation
+
+- [x] Sync preserves foreign catalogue source, plugin ref, and cache bytes; refresh targets only proven AIDD catalogues — `cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts:775`, `cli/scripts/smoke-collision.sh:69`.
+
+### Phase 2 — Finish project-local removal
+
+- [x] Marketplace removal and plugin uninstall clean A's local integration before detachment, retaining claims on local failure and leaving B/machine files intact — `cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts:106`, `cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts:72`.
+
+### Phase 3 — Prove current host source
+
+- [ ] Fresh native registration across A/B is partial: Codex has structured source proof, but Copilot 1.0.83 cannot activate even on a new profile — `cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts:4`.
+- [x] Copilot refusal leaves foreign repository settings and native state untouched — `cli/scripts/smoke-collision.sh:81`.
+- [x] Native readers use verified structured shapes and refuse unsupported outputs — `cli/src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.ts:45`.
+- [x] User clean rejects orphan/unclaimed machine-global refs and project clean leaves another user's ref enabled — `cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts:316`, `cli/src/contexts/framework/application/clean-use-case.ts:248`.
+- [x] Multi-tool user clean checks required binary availability before the first host mutation — `cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts:1158`.
+- [ ] Final-head CI remains pending. Local `framework` passed at 93.5837%; the preceding published head passed twelve other mutation scopes in CI, but `tools-codex` failed at 90% against its 94% floor — `cli/mutation-scopes.json`.
+
+### Phase 4 — Preserve edited local integration
+
+- [x] Edited Cursor hooks/scripts and OpenCode MCP contributions are kept; clean/remove only unedited A entries without touching B — `cli/src/contexts/framework/application/shared/remove-project-hooks.ts:79`, `cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts:1`.
+- [x] Failed local cleanup preserves A's reference and blocks shared user clean — `cli/tests/contexts/framework/application/clean-use-case.unit.test.ts:796`.
+- [x] Hook preflight precedes source-claim detachment and host changes — `cli/src/contexts/framework/application/clean-use-case.ts:204`.
+- [x] Symlinked project integration outside the canonical root is refused without changing external bytes — `cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts:62`.
+- [x] Targeted behavior tests, full functional gates, and the whole-framework mutation floor pass at 93.5837% — `cli/reports/mutation/framework/mutation.json`.
+
+### Phase 5 — Guard user-scope files
+
+- [x] Edited user plugin files survive update, remove, marketplace remove, and user clean; A's unedited contribution can still detach — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:257`, `cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts:112`.
+- [x] A user-created new-path collision is refused before update overwrites it — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:279`.
+- [x] A mixed native/file collision prevents host call, file writes, and claim movement — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:305`.
+- [x] A substituted symlink is rechecked before file I/O; external bytes survive deterministic tested cases — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:332`.
+- [x] Partial update failure retains truthful claims for manual reconciliation — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:380`.
+- [x] Targeted behavior tests, integrated functional gates, and the whole-framework mutation floor pass at 93.5837% — `cli/reports/mutation/framework/mutation.json`.
+
+## Findings
+
+| Sev | Kind | Phase | Location | Issue | Fix |
+| --- | --- | --- | --- | --- | --- |
+| 🔴 critical | functional | 3 | `cli/mutation-scopes.json` | Framework hardening is published in `58b5e92b`, with a local 93.5837% result and matching source. The preceding head passed twelve other mutation scopes in CI; Codex, Smoke, and Windows failed. Final-head gates are not yet certified. | Correct the failing contracts and verify CI on the final published head before a merge-ready verdict. |
+| 🟡 warning | functional | 3 | `cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts:4` | Copilot 1.0.83 safely refuses even fresh native activation; #829's preservation need is met, but end-to-end Copilot install is partial. | Verify a structured source reader on a supported binary in a separate follow-up before claiming full support. |
+| 🟡 warning | functional | 5 | `cli/src/contexts/framework/application/plugin/plugin-helpers.ts:58` | User clean validates paths with an injected home, but deletion resolves OS home. Normal OS-home deletion passes; custom injected-home deletion is not certified. | Align validation and deletion home resolution in a bounded follow-up with a custom-home witness. |
+
+## Verification
+
+| Metric | Value |
+| --- | --- |
+| Verified | 89.5% (17/19) |
+| Files checked | `cli/src/contexts/framework/application/{flows,clean,plugin,ownership,shared,uninstall}`, `cli/src/contexts/tools/{domain,infrastructure}`, relevant E2E/integration tests, `cli/scripts/smoke-collision.sh`, task plan and issue #829 |
+| Unchecked | Phase 3 fresh Copilot — follow-up; final-head CI, including Codex mutation, Smoke, and Windows — verification pending. Twelve other mutation scopes passed on the preceding head, not a fresh certification of the final tree. Custom-home cleanup is a separate diagnostic finding. |
+| Unplanned | Measured bundle-budget increase and smoke harness adaptation support delivery verification; no unrelated feature found. |
+| Draft disposition | User-authorized partial draft only; do not merge or close #829 until the strict gates and Copilot scope decision are resolved. |
+
+## Incremental local test hardening — 2026_09_16
+
+- Tests only: 236 additional cases since the 6,464-test snapshot; production source is unchanged during these lots. No commit or push.
+- Latest functional run: `pnpm exec vitest run --reporter=dot` passed 524 files / 6,700 tests in 35.16s. Coverage was last measured before the 143 latest tests: 99.09% lines, 96.80% branches, 99.68% functions, with 6,557 tests passing in 74.95s. `pnpm typecheck`, `pnpm lint`, and `git diff --check` passed; lint retains only the existing unused constructor-property warning at `uninstall-use-case.ts:33`.
+- Twenty-one targeted reports under `cli/reports/mutation/829-*/mutation.json` show 465 unique additional detections against the original baseline, without double counting overlapping campaigns. The original full report and incremental state are preserved under `cli/reports/mutation/framework-baseline-829/`; the preceding 90.0595% snapshot is preserved under `cli/reports/mutation/framework-90-before93/`.
+- The final whole-framework incremental run used the existing runner's exported scope arguments, pruning, scoring, and floor check, with concurrency limited to two. It reused 5,954 results, replayed 2,275 mutants, and finished in 9m01s: 7,688 killed, 13 timed out, 465 survived, 63 uncovered; score 93.5836675173168% on 8,229 mutants. The declared 93% gate passed. All 115 reported source files match the frozen working tree; production source, mutation configuration, exclusions, and scripts were unchanged during hardening.
+- Exact comparison with the original baseline gives 563 additional detections and no detection regression. Two unmatched mutation keys concern the previously documented sync warning wording. Against the preceding 90.0595% snapshot, 291 newly detected mutants and one detection loss give a net gain of 290. The retained loss is a static empty-string mutant in `plugin-distribution-reader-adapter.ts:20`; it was also undetected in the original baseline. Its status is not replaced with an earlier favorable result. The framework gate is passed, but the strict overall delivery verdict remains blocked by the other thirteen scopes and the documented partial Copilot verification.
+- Covered additions: post-add source/root/type mismatches and unreadable proof, local alias versus host identity, project versus machine catalogue proof, exact attachment to an already-enabled machine ref while preserving B, exclusive-access update preflight, affected-project warnings, and original I/O error causes.
+
+## Publication and CI corrections — 2026_09_17
+
+- User-authorized publication: `58b5e92b` and `3946be7f` pushed to PR #870 with hooks active. Pre-commit architecture and lint, commitlint, and pre-push Knip and the full functional suite passed. The latest suite passed 525 files / 6,726 tests. The PR remains draft; no merge or issue closure.
+- Windows reproduction: the file doubles registered `/project/link`, while callers used a resolved drive-qualified path. Two isolated Windows-path regression tests failed before correction. Symlink and `realpath` fault keys now resolve paths consistently; file-content keys remain unchanged. The focused four-file run passed 57 tests, including external-symlink refusal and project-boundary cases.
+- Smoke now selects the native refusal contract only when Copilot is available; without it, local removal must succeed and warn that the native CLI is unavailable, followed by local reinstall. Both available-host and missing-host runs passed all 33 command families with zero failures. The native refusal run preserved exact project/home bytes.
+- The previous Codex report matched its six source files but omitted the new native marketplace source parser, so its 96.8504% was not a current whole-scope certificate. Added malformed listing/source, mixed-row rejection, and actionable diagnostic witnesses through the public source reader; its 31 integration tests pass. The first fresh complete Codex run passed at 94.1176% on 459 mutants (430 killed, two timed out, 27 survived), covering all seven matching source files. A final run including four added diagnostic cases is under verification, with the 94% floor unchanged.
+- Codex consolidation: the diagnostic cases reduce survivors to 21. A high-concurrency run overlapping the full suite reported 11 timeouts; it is not used as evidence of stability. A subsequent complete-scope run at concurrency two passed at 95.4248366% in 4m02s, with zero timeouts and the 94% floor unchanged.
+- CI on `3946be7f` confirms Smoke passes, but Windows reports 37 failures because resolving every virtual `realpath` identity changed seeded project-reference identities. Two Windows-model tests reproduce the regression. The correction resolves symlink lookup keys while preserving unlinked paths and declared target identities; no production behavior or refusal assertion is relaxed. Final-head Windows verification remains pending.
+- CI on `95497fee` confirms the external-symlink refusals and project-reference identity cases pass. Three Windows failures remain in assertions introduced by the mutation hardening: two cache-warning expectations hardcode POSIX separators, and a cache-neighbor list expectation ignores the double's slash-normalized keys. Expected diagnostics now use platform `join`; the neighbor-list expectation uses the documented normalization while retaining exact path and byte-preservation assertions. Final-head CI remains pending.
+- Runtime bottleneck: in the final run Stryker estimated that 91 static mutants (4% of mutants scheduled for replay) would account for 61% of execution time. None were ignored. Harness optimization must preserve their witness coverage and sound cache invalidation.
+- Marketplace-removal additions cover reserved shared-catalogue refusal before registry access, exact user-catalogue selection among competing entries, exact native scope and host name, implicit host ref scope, a proven empty catalogue without plugin claims, missing canonical ledger, and project cleanup without a native mapping or orphan. The two targeted campaigns took 17s and 21s; the final report contains 202 killed / 10 survived across 212 mutants. The surviving empty file-scope literal still resolves to the same user directory; no invalid runtime scope was introduced just to distinguish it.
+- Synchronization additions in `cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts` and `marketplace-sync-source-provenance.integration.test.ts` cover implicit/explicit project synchronization inside the machine lock, user-scope non-reentrancy, lock failure before project reads or host writes, missing/unreadable plugin registries with and without a diagnostic, explicitly absent plugin registries followed by post-add source proof, and Claude catalogue absence versus late unreadability or a newly appearing same-name foreign catalogue. The two targeted campaigns took 50s and 53s; the latest report contains 96 killed / 16 survived / 0 uncovered across 112 mutants. Their overlapping gains were counted once.
+- Late-refusal/projection additions in `cli/tests/contexts/framework/application/flows/marketplace-sync-native-provenance.integration.test.ts` cover a transient source-read refusal followed by recovered proof, foreign/missing source, foreign refs or unreadable refs, for Codex and the future-supported Copilot test double. Existing project references and B's machine claims survive invalid proof; recovered proof attaches only this project. Narrowed sync drops obsolete target references only after valid proof and retains references when another alias still owns the same host catalogue. The Claude late-collision test also explicitly asserts an empty project projection. `829-sync-late-refusal-projections/mutation.json` matches current source: 130 killed / 17 survived / 1 uncovered across 148 mutants, in 44s; 19 detections beyond the complete snapshot, of which two overlap the preceding registry-proof lot, so the net addition is 17. No claim of real Copilot support follows from these doubles.
+- Catalogue-versus-plugin additions in `cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts` cover project/user Claude catalogue refusal without phantom machine claims, a catalogue containing an undeclared plugin without enabling or claiming that plugin, scope-specific machine catalogue ownership for Claude/Codex, idempotent ownership saves, preservation of B's existing claims and tool version, another alias pointing at the same host, and fresh machine tool/version initialization. `829-sync-catalogue-plugin-claims/mutation.json` matches current source: 109 killed / 13 survived / 0 uncovered across 122 mutants, in 41s; 14 detections beyond the complete snapshot, of which five overlap preceding lots, so the net addition is nine.
+- The first threshold-93 batch, `829-threshold93-native-recovery/mutation.json`, measured 152 novel detections beyond every preceding targeted lot: clean 39, sync 32, plugin removal 27, hooks 28, plugin add 7, init 7, native registration gate 7, file updater 5. It completed in 2m58s across 1,034 mutants. Fourteen formerly detected mutations survived in this narrower run; these discrepancies are retained for the whole-framework replay, not omitted from the verdict. Added witnesses assert preflight state preservation, native cache retention on binary loss, exact host diagnostics, recovery races and scope, public upgrade/no-op behavior, and hook contribution ownership. The test skill's behavioral contract ruled out contrived invalid inputs for unreachable branches.
+- Separate diagnostic finding: `CleanUserScopeUseCase` checks user files with its injected home directory, but `deletePluginFilesForTool` resolves deletion through OS `nodeHomedir` (`cli/src/contexts/framework/application/plugin/plugin-helpers.ts:58`). The truthful OS-home test proves normal user-file deletion; it does not certify custom injected-home deletion. No production workaround was added to raise the mutation score.
+- The final targeted batch, `829-threshold93-final-contracts/mutation.json`, added another 61 novel detections: user clean 21, sync 17, native source proof 5, cache purge 6, built materialization 3, runtime settings 3, plugin removal 2, tool uninstall 4. It completed in 1m45s across 508 mutants. All narrow-run discrepancies were left for the complete replay; the reported final global score, not the sum or average of targeted scores, is the certified result.
+- Threshold-93 objective complete. Remaining delivery work: verification of the other thirteen scope gates and the existing Copilot follow-up; custom-home cleanup is separately documented. No legacy migration, commit, or push was performed.
diff --git a/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/backlog-link.json b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/backlog-link.json
new file mode 100644
index 000000000..75e33474d
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/backlog-link.json
@@ -0,0 +1,5 @@
+{
+ "backlog": "ai-driven-dev/framework#250",
+ "written_at": "2026-09-18T09:24:15Z",
+ "written_by": "aidd-pm:04-spec"
+}
diff --git a/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/phase-1.md
new file mode 100644
index 000000000..61e8d98f7
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/phase-1.md
@@ -0,0 +1,106 @@
+
+# Instruction: The two rules, as a tested engine
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+.
+├── scripts
+│ ├── lib
+│ │ └── architecture-rules.js ✅ the two rules, as pure functions
+│ └── __tests__
+│ ├── architecture-rules.test.js ✅ both rules, both directions, plus the clean-tree sweep
+│ └── fixtures
+│ └── architecture-rules ✅ synthetic plugin trees, written for this task
+└── docs
+ └── ARCHITECTURE.md ✏️ one line pointing at the guard that now enforces the rule
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[A contributor edits a plugin source] --> B{Does the prospective content break a rule?}
+ B -- no --> C[Nothing is said]
+ B -- "a sibling plugin is addressed" --> D[File, line, owning plugin]
+ B -- "the Actions section is out of step" --> E[File, line, the action that does not match]
+ D --> F[The contributor corrects it and edits again]
+ E --> F
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ Write the synthetic plugin fixtures under the test fixtures directory => fixtures on disk: 5: system
+ section Happy path
+ Run the rule engine over a fixture whose skill addresses only its own plugin => no violation: 5: cli
+ Run the rule engine over the repository's own plugins directory => no violation: 5: cli
+ section Edge case - a recipe skill addresses a sibling
+ A skill file names another plugin's skill => run the engine over it => one violation naming the file, the line and the owning plugin: 1: cli
+ section Edge case - a permission list addresses a sibling
+ An agent lists a sibling skill under its permission heading => run the engine over it => no violation: 1: cli
+ section Edge case - an orchestration reference addresses a sibling
+ An orchestrator reference names a provider => run the engine over it => no violation: 1: cli
+ section Edge case - an action file no section names
+ A skill gains an action file its Actions section never mentions => run the engine over it => one violation naming the file: 1: cli
+ section Edge case - a section whose mention is only prose
+ A word in the section's prose matches an action's stem but no row cites it => run the engine over it => one violation naming the file: 1: cli
+```
+
+## Tasks to do
+
+### `1)` The failing tests come first
+
+> Every rule gets its red before it gets its engine.
+
+1. Write the synthetic fixtures: a plugin tree with a clean skill, a skill addressing a sibling, an agent permission list addressing a sibling, an orchestrator reference addressing a sibling, a skill with an unnamed action file, a skill citing an absent action.
+2. Write `architecture-rules.test.js` covering each fixture and each expected verdict, plus one case that sweeps the repository's real `plugins/` and expects zero violations.
+3. Run the suite and watch every case fail for the absence of the module, not for a typo.
+
+### `2)` The orthogonality rule
+
+> A dispatch surface never names a sibling plugin.
+
+1. Expose a function taking a repository-relative path and the prospective content, returning violations with a line, a 1-indexed number, the address found and the plugin that owns it.
+2. Match `/:` and `@:` addresses; a match whose plugin equals the file's own owner is not a violation.
+3. Govern only `SKILL.md`, `actions/*.md`, `references/*.md` and `agents/*.md`. Anything under `assets/` returns nothing.
+4. Exempt a file owned by an orchestrator plugin, and exempt the lines under an agent's `# Skills you may invoke` heading.
+
+### `3)` The router coherence rule
+
+> An Actions section cites every action that exists.
+
+1. Take the prospective `SKILL.md` content and the names of the skill's action files.
+2. Isolate the `## Actions` section, up to the next second-level heading.
+3. Report an action file the section cites by neither its stem, its file name, nor an `actions/.md` path. Collect a citation from the table's action column, never from running prose.
+4. Report the line of the section heading. A citation with no file behind it is deliberately not reported — it cannot be told apart from one written just before the file it names.
+
+### `4)` The rule the repository already follows
+
+> A guard that is red on a clean tree is a guard nobody keeps.
+
+1. Run the sweep case over the real `plugins/` tree.
+2. When a rule fires there, the rule is wrong, not the tree. Narrow it and record why in `plan.md`'s decisions.
+
+### `5)` The rule document points at its enforcement
+
+> `docs/ARCHITECTURE.md` states the rule; say where it is now checked.
+
+1. Add one sentence naming the guard, plugin-relative and in backticks, never as a link.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | Every case in the suite fails before the engine exists, each for the missing module |
+| 2 | A skill addressing a sibling yields a violation naming file, line and owning plugin; an agent permission list and an orchestrator reference yield none; a file under `assets/` yields none |
+| 3 | An action file the section never cites yields one violation; a stem appearing only in prose does not count as a citation; a skill whose section and files agree yields none |
+| 4 | Sweeping the repository's own `plugins/` yields zero violations |
+| 5 | `docs/ARCHITECTURE.md` names the guard, and the markdown-link check still passes |
diff --git a/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/phase-2.md
new file mode 100644
index 000000000..27c71e1f2
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/phase-2.md
@@ -0,0 +1,103 @@
+
+# Instruction: The refusal, at the AI host's write moment
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+.
+├── .claude
+│ ├── hooks
+│ │ └── check-architecture-rules.js ✅ reads the pending edit, calls the engine, refuses
+│ └── settings.json ✏️ one PreToolUse entry matching Write and Edit
+└── scripts
+ └── __tests__
+ └── architecture-hook.test.js ✅ the payload shapes and the refusal contract
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[An agent calls Write or Edit on a plugin source] --> B[PreToolUse hands the hook the pending content]
+ B --> C{Does the prospective content break a rule?}
+ C -- no --> D[The write proceeds]
+ C -- yes --> E[The call is denied, with file, line and owning plugin]
+ E --> F[The agent corrects the content and calls again]
+ F --> B
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ Build a PreToolUse payload for a plugin file => payload on stdin: 5: system
+ section Happy path
+ Run the hook on a payload whose content breaks no rule => exit zero and no output: 5: cli
+ section Edge case - a Write that introduces a sibling address
+ The payload carries content addressing another plugin => run the hook => a deny decision naming file, line and owning plugin: 1: cli
+ section Edge case - an Edit that introduces a sibling address
+ The payload carries an old and a new string => run the hook => the reconstructed content is judged, and the call is denied: 1: cli
+ section Edge case - an Edit whose old string is absent
+ The payload cannot be reconstructed => run the hook => exit zero, because the edit will fail on its own: 1: cli
+ section Edge case - a file outside the governed surface
+ The payload names a file under assets => run the hook => exit zero: 1: cli
+ section Teardown
+ Remove the temporary payload files => baseline restored: 5: system
+```
+
+## Tasks to do
+
+### `1)` The hook, generated by the capability that owns hooks
+
+> The decider asked for a host-native lifecycle hook produced by the hook capability, not a hand-placed script.
+
+1. Run `/aidd-context:08-hook-generate` for a `PreToolUse` hook matching `Write|Edit`, at the project scope, for Claude Code — the one host this repository configures hooks for, per the plan's decisions.
+2. Keep the generated entry and script; give the script the name the projection states.
+
+### `2)` The prospective content
+
+> Judge what the file will hold, not what it holds now.
+
+1. Read the payload from standard input. Take `tool_input.file_path`.
+2. For a `Write`, the prospective content is `tool_input.content`.
+3. For an `Edit`, read the file and apply `old_string` to `new_string`, once or everywhere per `replace_all`.
+4. When the file path is outside the governed surface, or the reconstruction fails, exit zero and say nothing.
+
+### `3)` The refusal
+
+> A refusal a reader cannot act on is noise.
+
+1. Call the engine with the path, the prospective content, and the skill's action file names when the path is a `SKILL.md`.
+2. On a violation, emit the `PreToolUse` deny decision, with a reason naming each file, line, and the plugin that owns the address, and what to write instead.
+3. On no violation, exit zero with no output.
+
+### `4)` The failing tests come first
+
+> The hook is a contract with the host; test it as one.
+
+1. Write `architecture-hook.test.js` driving the hook as a subprocess with each payload shape.
+2. Watch each case fail before the hook exists.
+3. Assert the decision is a deny and the reason carries the file and the line, never only the rule.
+
+### `5)` The proof
+
+> A guard nobody watched fire is a comment.
+
+1. Write a plugin file addressing a sibling, through the agent's own Write tool, and record that the call was refused and what it said.
+2. Write an agent permission list naming a sibling the same way, and record that it was applied.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | The hook entry exists in `.claude/settings.json` at the project scope and fires on `Write` and `Edit` |
+| 2 | A `Write` payload and an `Edit` payload over the same file yield the same verdict for the same resulting content |
+| 3 | A denied call returns a reason naming file, line and owning plugin; a clean call returns nothing at all |
+| 4 | Every case fails before the hook exists, then passes |
+| 5 | An attempted write of a sibling address is refused in the same turn; an attempted write of a permission list is applied |
diff --git a/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/plan.md b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/plan.md
new file mode 100644
index 000000000..03e781163
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/plan.md
@@ -0,0 +1,48 @@
+
+# Plan: Architecture guard on AI-authored edits
+
+## Overview
+
+| Field | Value |
+| --- | --- |
+| **Goal** | Refuse an AI edit that would hardcode a sibling plugin's address, or leave a skill's `## Actions` section out of step with its action files, before the edit lands. |
+| **Source** | [`spec.md`](./spec.md), from [ai-driven-dev/framework#250](https://github.com/ai-driven-dev/framework/issues/250) |
+
+## Phases
+
+| # | Phase | File |
+| --- | --- | --- |
+| 1 | The two rules, as a tested engine | [`phase-1.md`](./phase-1.md) |
+| 2 | The refusal, at the AI host's write moment | [`phase-2.md`](./phase-2.md) |
+
+## Resources
+
+| Source | Verified |
+| --- | --- |
+| `docs/ARCHITECTURE.md`, capability addressing | A capability is addressed only where dispatch is declared: a router's `## Actions` table, an agent's `# Skills you may invoke` list. Agent permission lists and orchestration references legitimately name a provider; recipe skills never do. |
+| | `PreToolUse` receives `tool_input` before the tool runs — `content` for `Write` — and refuses the call with `hookSpecificOutput.permissionDecision: "deny"` plus a `permissionDecisionReason` the model reads. `PostToolUse` cannot refuse, it only reports after the fact. |
+| `.claude/hooks/check-written-file.js` | The in-repo precedent for a hook that reads the payload from stdin, resolves the written file, and hands a report back in the same turn. |
+| Issue #250, comment of 2026-09-14 | Supersedes the issue body's "Guardrail local et CI": no Git hook, no CI gate, synthetic fixtures, #406 neither blocker nor fixture. |
+| Probe over the 51 skills and 8 plugins in the tree | Both rules, as scoped below, report zero violations on the repository as it stands — orthogonality across 37 real cross-plugin addresses, router coherence across 48 skills that hold action files. The probe measured both directions; only the decidable one ships, per the decision below. |
+
+## Decisions
+
+| Decision | Why |
+| --- | --- |
+| `PreToolUse`, not `PostToolUse` | The decision is "prevent the write until it is corrected". `PostToolUse` fires after the file is already on disk; Biome gets away with it only because it rewrites in place, and this guard cannot rewrite prose. |
+| The engine is a plain module, the hook is a thin caller | A rule that is a pure function of (path, prospective content, action-file listing) is testable without a hook, a host, or a tree. The hook contributes only the payload and the refusal. |
+| The governed surface is `SKILL.md`, `actions/`, `references/`, `agents/` | That is where dispatch is declared. `assets/` hold sheets a reader reads — `12-cook`'s recipes name 20 cross-plugin commands on purpose — so they are excluded by a stated rule, never by a quiet path filter. |
+| An orchestrator plugin is exempt from rule one, and only rule one | `docs/ARCHITECTURE.md` makes orchestration references responsibility maps. `aidd-orchestrator` holds 14 of the tree's cross-plugin addresses for exactly that reason. Router coherence is a different rule and applies to it like any other plugin. |
+| The router rule reads the `## Actions` section, not the table | `10-todo` names its one action as a path in a fenced block rather than a table row. Scoping to the section and matching either the stem or the file name covers both shapes and still reports zero on the tree. |
+| Unit tests live under `scripts/__tests__/`, and that is not a CI gate on the rules | Pre-commit runs those tests against synthetic fixtures, proving the engine works. Nothing scans the tree at commit time or in CI, which is what the decider ruled out. |
+| Fixtures are written for this task | The spec forbids #406's historical code. Each rule gets a breaking fixture and a legitimate-naming fixture, so a guard that flags a permission list fails its own suite. |
+| The hook is wired for Claude Code alone | It is the only host this repository configures hooks for: `.codex/config.toml` carries a sandbox mode and nothing else. Codex, Cursor and Copilot all expose `PreToolUse` and the same deny shape, but Codex delivers a file edit as an `apply_patch` command string rather than a path and a content, which is a different parse. The engine is host-agnostic, so each adapter is additive and none of them touches a rule. |
+| Rule two enforces one direction, not two | A citation with no file behind it cannot be told apart from a citation written just before the file it names. Enforcing it deadlocked adding an action: creating the file first was refused for not being named, naming it first was refused for having no file, and `aidd-context:04-skill-generate` documents the second order. The decidable direction is kept. |
+| An action is named by a citation, never by a word | Plain containment over the section let ordinary prose pass for a mention: `plan` appears in "the plan is the culmination", so deleting that action's row went unnoticed. Measured over the tree, containment missed 20 of 78 row deletions; citation matching misses none that has a row. |
+| One plugin source was repaired, and it is the exception | `plugins/aidd-dev/skills/01-plan/actions/04-plan.md:16` read "declare it the same way `aidd-pm:04-spec` does", which is rule one's own violation in prose. The spec's non-goal keeps existing violations out of scope, and the hard constraint says a rule red on the tree is miscalibrated — so the one line that was genuinely wrong was fixed, and the seven in `00-onboard` were exempted with #883 behind them instead. No other plugin source is touched. |
+| A stem speaks for its action only when no sibling shares it | `01-plan.md` and `04-plan.md` both reduce to `plan`, so one row would cover both and deleting either would go unnoticed. A shared stem cites nobody; the numbered name still does. No skill in the tree has a collision today, which is why it had to be reasoned about rather than observed. |
+| A fenced block is an example, never a router | The section scan blanks fences before reading headings and tables, so a documented `## Actions`, a `##` inside the section, and an example table all stop steering the verdict. Path citations are read from the unblanked lines, because `10-todo` legitimately cites `actions/01-todo.md` inside a fence. |
+| Only the path shape reads through a fence | Both shapes read the unblanked lines at first, which let a backticked `.md` inside a fenced example cite — the very hole blanking fences was for. `10-todo` needs the path shape and nothing needs the other, so the exception is one shape wide. |
+| A table resumed after a blank line keeps its column | A run of rows with no `| --- |` beneath it is read as the table above it, resumed. Otherwise inserting one blank line — changing no content — refuses every row below it. The cost: a separator-less glossary written just under the router donates its cells, so a deleted row could hide behind it. Neither shape is a table a renderer accepts, and the false refusal is the worse failure. A column-count check does not separate them: a glossary often has the router's width. |
+| A separator row counts from two dashes | `aidd-context:00-onboard` writes `| -- |` and GitHub renders it. Requiring three silently stopped the header being recognised, which unnamed every action in that skill. The real-tree sweep caught it. |
+| A fence nobody closed is not a fence | Blanking from an unterminated fence to end of file hides a `## Actions` that is visibly there, and the refusal then reads "has action files but no ## Actions section" — unactionable. The whole document is read as unfenced instead. |
diff --git a/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/spec.md b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/spec.md
new file mode 100644
index 000000000..9addb4bbf
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/spec.md
@@ -0,0 +1,58 @@
+# Architecture guard on AI-authored edits
+
+## Target
+
+An AI-authored edit that would break one of this repository's two named architecture rules is refused before it lands, with the offending file and line named.
+
+## Hard constraints
+
+- The guard decides before the edit is applied and refuses it. It is not a commit-time gate and not a continuous-integration job.
+- The guard is deterministic: the same prospective file content yields the same verdict, with no dependence on model judgement and none on which AI tool made the edit.
+- The guard is produced by the project's own hook-generation capability, so it exists for the AI hosts this repository configures rather than for one.
+- Rule one, cross-plugin orthogonality: a plugin's dispatch surface must not name a sibling plugin by a hardcoded address.
+- Rule two, router coherence: a skill's `## Actions` section must cite every action file that skill provides. A citation is a table cell, a fenced `actions/.md` path, or a backticked file name — never a bare word in running prose.
+- The governed surface is the dispatch surface: a skill's `SKILL.md`, its actions and its references, and an agent's definition. A plugin's `assets/` hold content shown to a reader, not dispatch, and are out of the guard's reach by this rule rather than by an unstated path filter.
+- Naming that `docs/ARCHITECTURE.md` declares legitimate stays silent: an agent's permission list and an orchestration reference are responsibility maps and name their provider canonically. Flagging either is a defect of the guard, not of the tree.
+- Every refusal names the file, the line, and the plugin that owns the addressed capability, in terms a reader can act on without opening the rule document.
+- The guard is silent on the repository as it stands. A fenced block holds an example, never a router: a `## Actions` inside one is not the section, a `##` inside one does not end it, and a table inside one cites nothing. A fenced `actions/.md` path is the one exception, because `aidd-dev:10-todo` cites its only action that way — so a fenced example writing that shape does cite, and can mask a row deleted elsewhere. Two more shapes are read generously and stay that way: a run of table rows with no separator under it is taken for the table above it resumed after a blank line, so a separator-less glossary written just below the router would donate its cells; and rule one reads addresses through fences, so a governed file teaching a counter-example is refused. Neither shape is a table a renderer accepts, and a refusal nobody can act on is the worse failure. A rule that reports a violation in the current tree is miscalibrated, not vindicated.
+- The rules are exercised by purpose-built fixtures. No historical code from #406 is used as a fixture.
+- Each rule is proved by a fixture that breaks it and turns exactly the test named for that rule red, and by a fixture of legitimate naming that stays green.
+
+## Non-goals
+
+- A `lefthook` pre-commit gate for these rules. The decider ruled it out on 2026-09-14.
+- A CI job enforcing these rules.
+- Judging whether a skill's prose `description` has gone stale. Nothing mechanically separates stale prose from current prose, so the issue's "router/description mismatch" is served by the router half alone.
+- Refusing a citation with no action file behind it. A citation written seconds before the file it names is indistinguishable from a stale one, and this project's own skill generator writes the router first, so enforcing that direction makes adding an action impossible in either order. The decidable direction — an action file the section never cites — is the one the issue names, and the one enforced.
+- Catching an action file created and never cited. Rule two decides on a write to a `SKILL.md`; an orphan action file is caught at the next write to its skill's router, not at its own creation. Firing on the action file is what broke the order that creates the file first, and dropping it was the cheaper half of breaking the deadlock. The window is unbounded: that next write may never come, so this is a hole, not a delay.
+- Reaching into a plugin's `README.md`, which addresses a sibling in two places today. Like `assets/`, it is a document a reader reads, not a dispatch the skill executes.
+- Reaching into a plugin's `assets/`. A recipe sheet names the commands a reader types; that is its subject, not a dispatch this rule governs.
+- Repairing violations that exist in the tree today. That was #406, now closed. One line is the stated exception, named in the plan's decisions: a prose sentence in `aidd-dev`'s planning action addressed a sibling skill outright, and leaving it would have made the guard red on its own tree.
+- Enforcing any architecture rule beyond the two named above.
+- Catching a violation introduced outside an AI tool, by a human editing by hand.
+- Catching a write performed through a shell command rather than a write tool. Deciding whether a shell line writes a plugin source means parsing arbitrary shell, which is not the deterministic verdict rule one requires. An agent told to edit through `sed` or a heredoc is therefore ungoverned, and that is stated here rather than left to be discovered.
+- Making `aidd-context:00-onboard` resolve its providers at runtime. Its reference menus name addresses because those addresses are what it hands a person to type, so it is exempt by a named rule with a follow-up issue behind it, not by silence.
+- Shipping the guard into projects that install this marketplace. The rules govern this repository's own plugin sources.
+
+## Done-when
+
+- An edit that would leave a skill's dispatch surface holding a sibling plugin's hardcoded address is refused, and the refusal names that file, its line, and the plugin that owns the address.
+- An edit that would leave a skill's `## Actions` section not naming an action file the skill provides is refused, and the refusal names the file and the line. The section names an action by citing it, so a word in prose that happens to match a stem never passes for a mention.
+- An edit that writes an agent permission list, or an orchestration reference, naming its provider canonically is applied with no complaint.
+- Breaking each rule in its fixture turns red exactly the test named for that rule, and no other test.
+- The refusal reaches the author in the same turn as the edit that caused it, before any commit, push, or CI run.
+- Every plugin source in the repository as it stands passes both rules.
+- A contributor who has never read `docs/ARCHITECTURE.md` can correct a refused edit from the refusal text alone.
+
+## Stakeholders
+
+- Decider: the repository owner, who ruled out the commit-time and CI mechanisms on 2026-09-14.
+- Owner: the framework maintainers.
+- Consumer: every contributor and every agent that edits a plugin source in this repository.
+
+## Context
+
+- Backlog item: [ai-driven-dev/framework#250](https://github.com/ai-driven-dev/framework/issues/250).
+- The `00-onboard` exemption has an expiry, not a pass: [ai-driven-dev/framework#883](https://github.com/ai-driven-dev/framework/issues/883) makes that skill resolve its providers at runtime, and closing it closes the exemption.
+- The issue body's "Guardrail local et CI" section predates the 2026-09-14 comment on the same issue and is superseded by it. The comment is the governing statement: no Git hook, no CI gate, synthetic fixtures, #406 neither blocker nor fixture.
+- The orthogonality rule and its legitimate exceptions are stated in `docs/ARCHITECTURE.md`, under capability addressing: a capability is addressed only where the dispatch is declared, and elsewhere the concept is named instead of the skill that owns it.
diff --git a/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/wiring-proof.md b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/wiring-proof.md
new file mode 100644
index 000000000..7d81908d9
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_18_cross-plugin-orthogonality-guard/wiring-proof.md
@@ -0,0 +1,53 @@
+# Wiring proof
+
+Phase 2's task 5 asks for a recorded live refusal, because everything else about this guard
+is verified upstream of the seam that matters: the tests spawn the hook script and pipe it a
+payload, which proves the script and never proves the wire. The matcher string, the
+`$CLAUDE_PROJECT_DIR` expansion inside the command, and `node` being on `PATH` are only
+exercised by a real tool call — and the hook fails open at every one of those steps, so a
+broken wire looks exactly like a clean tree.
+
+Both calls below were made with the Write tool, in this repository, against
+`.claude/settings.json` as committed.
+
+## A refusal
+
+Writing `plugins/aidd-dev/skills/03-assert/references/wiring-proof.md`, a reference file in a
+recipe skill, with this body:
+
+```md
+# Wiring proof
+
+Hand the result to `/aidd-vcs:01-commit` once every assertion passes.
+```
+
+The call was refused. Verbatim, as it reached the author:
+
+```text
+plugins/aidd-dev/skills/03-assert/references/wiring-proof.md:3 addresses sibling plugin "aidd-vcs" via "/aidd-vcs:01-commit". Fix: name the concept aidd-vcs owns instead of addressing it directly.
+```
+
+The file was never created — `ls` on that path answers `No such file or directory`.
+
+## The same address, legitimately
+
+Writing `plugins/aidd-dev/agents/wiring-proof.md` with the same address under the heading
+`docs/ARCHITECTURE.md` sanctions:
+
+```md
+# Skills you may invoke
+
+- `/aidd-vcs:01-commit`
+```
+
+The call was applied, with no output. The probe was removed in the same turn;
+`git status --porcelain plugins/` is empty.
+
+## What this settles
+
+| Claim | Settled by |
+| --- | --- |
+| The hook is reachable from the matcher as wired | the refusal arrived at all |
+| It decides before the write, not after | the refused path does not exist |
+| The refusal names file, line and owning plugin | the verbatim text above |
+| A legitimate permission list is not refused | the second call was applied silently |
diff --git a/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/backlog-link.json b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/backlog-link.json
new file mode 100644
index 000000000..f0dfabec7
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/backlog-link.json
@@ -0,0 +1,5 @@
+{
+ "backlog": "ai-driven-dev/framework#911",
+ "written_at": "2026-09-23T00:00:00Z",
+ "written_by": "aidd-dev:01-plan"
+}
diff --git a/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/brainstorm.md b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/brainstorm.md
new file mode 100644
index 000000000..32e653852
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/brainstorm.md
@@ -0,0 +1,28 @@
+# Credit contributors in release notes
+
+Refines [#911](https://github.com/ai-driven-dev/framework/issues/911).
+
+Every line of the generated release notes ends with the GitHub `@handle` of the commit's author, Symfony-style, for the root release and for each plugin and cli release. The point is to make everyone who ships visible, release after release, maintainer included. Attribution was meant to rely on release-please's native `include-commit-authors` option; a sandbox run proved it a no-op (upstream googleapis/release-please#2761), so a step post-processes each GitHub release instead. `main` accepts only merge commits, so a promote always keeps every commit and its author.
+
+## What Is Clear
+
+- Per-line credit only. No contributor section at the end of a release, no deduplication.
+- Everyone is credited, maintainer included.
+- A plugin release credits only the authors of that plugin's commits, because release-please builds each component's notes from its own commits.
+- `include-commit-authors` exists but credits nobody in 17.11.2: `parseConventionalCommits` drops the author. Fix pending upstream in #2892; the post-processing step is removed once it ships.
+- `changelog-type: github` is rejected: it reads the whole range between tags, which is wrong for component releases.
+- `(@dependabot[bot])` on dependency lines is accepted.
+- Verified on the last 60 PRs merged into `next`: the squash commit's author is the PR author (55/55 humans), even when someone else merges. Every commit email resolves to a GitHub account.
+- Promote to `main` is a merge commit today (`promote.yml`, #809), which keeps each commit and its author. It stays as is.
+- Guard: `main` allows only merge commits. A squashed promote would silently drop the week's entries (proven in the sandbox). The release PR moves to `--merge --admin`, and the live ruleset changes only after that has released once.
+- Only the GitHub releases carry the handles; `CHANGELOG.md` and the release PR body do not. Accepted.
+- A multi-author PR credits its author only; `Co-authored-by:` trailers are ignored. Accepted.
+- Out of scope: other forms of contribution (issues, reviews, discussions), and a weekly shout-out on overall contribution, which gets its own issue.
+
+## Still Open
+
+- None. Settled in planning; see `plan.md`.
+
+## Next Move
+
+Implement `plan.md`.
diff --git a/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/phase-1.md
new file mode 100644
index 000000000..4a226d025
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/phase-1.md
@@ -0,0 +1,89 @@
+---
+status: pending
+---
+
+# Instruction: Credit commit authors in GitHub releases
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+.
+├── scripts/credit-release-authors.cjs ✅ pure line transform plus a thin gh runner
+├── scripts/__tests__/credit-release-authors.test.js ✅ transform fixtures and ci.yml wiring
+├── .github/workflows/ci.yml ✏️ credit step after the release is created
+└── aidd_docs/memory/deployment.md ✏️ Release: releases credit authors, and why it is a step
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[Release PR merged] --> B[release-please creates up to nine releases]
+ B --> C[Credit step reads each created tag's release body]
+ C --> D[Each line's commit SHA resolved to its author]
+ D --> E["Line gains (@login), or the name when no account"]
+ E --> F[Release body updated once; a re-run changes nothing]
+```
+
+## Test Scope
+
+```mermaid
+flowchart LR
+ T[transform fixtures] --> T1[two contributors, each credited]
+ T --> T2[a repeated contributor, credited on each line]
+ T --> T3["a bot, credited as @dependabot[bot]"]
+ T --> T4[a line ending in closes #N, credited after it]
+ T --> T5[a line without a commit SHA, untouched]
+ T --> T6[a line already credited, untouched]
+ T --> T7[no login, name without @]
+ W[ci.yml wiring] --> W1[the step runs only when releases were created]
+ W --> W2[the step runs after the release step, with the App token]
+```
+
+## Tasks to do
+
+### `1)` Write the failing transform tests
+
+> Pin the line format on real release lines before any code.
+
+1. Create `scripts/__tests__/credit-release-authors.test.js` with T1 to T7, fixtures copied from `v5.10.0`'s real lines.
+2. The transform takes a body and a `sha → display` resolver, so no fixture touches the network.
+3. Run it and watch it fail on the missing module.
+
+### `2)` Write the transform and its runner
+
+> Pure function first, the runner only fetches and writes.
+
+1. Create `scripts/credit-release-authors.cjs` exporting `credit(body, resolve)`.
+2. A header comment links googleapis/release-please#2761 and #2892 and says to delete the script once a fixed release-please is pinned.
+3. The runner takes the created tags, reads each body with `gh release view`, resolves each SHA once with `gh api repos//commits/`, and writes back with `gh release edit --notes-file -` only when the body changed.
+4. An API failure exits non-zero and names the tag, never writes a partial body.
+5. Tests green. Mutation: drop the already-credited guard and watch T6 go red.
+
+### `3)` Wire the step
+
+> After the releases exist, on the run that created them.
+
+1. Read how `release-please-action` v5.0.0 exposes each created tag (root `tag_name`, per path `--tag_name`) from its `action.yml` or source before relying on it.
+2. Add a step to the `release-please` job, after "Pin umbrella release as latest", guarded on `releases_created`, passing the action's outputs to the runner and the App token as `GH_TOKEN`.
+3. Extend the test with W1 and W2, loading `ci.yml` with js-yaml.
+4. Mutation: drop the guard and watch W1 go red.
+
+### `4)` Document
+
+> Once, where the release is described.
+
+1. In `aidd_docs/memory/deployment.md`'s Release section, state that a step credits each release line's commit author, as a workaround for the upstream bug.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | The tests fail first for a missing module |
+| 2 | T1 to T7 pass. Removing the already-credited guard turns T6 red |
+| 3 | W1 and W2 pass. Removing the `releases_created` guard turns W1 red |
+| 4 | `pnpm exec lefthook run pre-commit` is green |
+| all | `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` is green |
+| live | the first release after merge shows `(@login)` on every line |
diff --git a/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/phase-2.md
new file mode 100644
index 000000000..029da516e
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/phase-2.md
@@ -0,0 +1,90 @@
+---
+status: pending
+---
+
+# Instruction: Restrict main to merge commits
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+.
+├── .github/workflows/ci.yml ✏️ release PR merged with --merge --admin
+├── .github/rulesets/main.json ✏️ pull_request rule: allowed_merge_methods ["merge"]
+├── scripts/__tests__/main-merges-by-merge-commit.test.js ✅ merge-method assertions
+├── aidd_docs/memory/deployment.md ✏️ Release step 1: merge commit, not squash
+└── RELEASE.md ✏️ Hotfix lands as a merge commit
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[Someone opens the merge menu on a PR to main] --> B{Method}
+ B -->|merge commit| C[Merged, every commit and author kept]
+ B -->|squash or rebase| D[Refused by the ruleset]
+ E[release-please job] -->|gh pr merge --merge --admin| C
+ F[promote.yml] -->|gh pr merge --merge --auto| C
+```
+
+## Test Scope
+
+```mermaid
+flowchart LR
+ T[structural test] --> T1[main.json allows only merge]
+ T --> T2[ci.yml merges the release PR with --merge]
+ T --> T3[no workflow merges into main with --squash or --rebase]
+ L[live check after rollout] --> L1[next release PR merges and tags]
+ L --> L2[back-merge folds main into next]
+```
+
+## Tasks to do
+
+### `1)` Write the failing assertions
+
+> Pin the merge method in the files before changing them.
+
+1. Create `scripts/__tests__/main-merges-by-merge-commit.test.js` asserting T1 to T3, loading the files as `release-covers-every-plugin.test.js` does.
+2. Watch T1 and T2 fail on the current files.
+
+### `2)` Switch the release PR to a merge commit
+
+> The one bot merge into `main` that squashes today.
+
+1. In `ci.yml`, replace `--squash --admin` with `--merge --admin` on the release PR merge, and update its comment.
+2. T2 and T3 green. Mutation: put `--squash` back and watch T3 go red.
+
+### `3)` Declare the ruleset change
+
+> The file, not the live ruleset.
+
+1. In `.github/rulesets/main.json`, add `"allowed_merge_methods": ["merge"]` to the `pull_request` rule's parameters.
+2. T1 green.
+
+### `4)` Document
+
+> Where the release and the hotfix are described.
+
+1. `deployment.md` Release step 1: the release PR merges as a merge commit; `main` allows no other method.
+2. `RELEASE.md` Hotfix: the PR lands as a merge commit, so each of its commits must be conventional.
+
+### `5)` Roll out, in this order, with the maintainer
+
+> The live ruleset changes last, only after the new merge has proven itself. Applied first, it refuses the `--squash --admin` release merge: proven in the sandbox.
+
+1. Ship phases 1 and 2 through `next` and a promote. The ruleset file changes; the live ruleset does not yet.
+2. Wait for that cycle's release PR to merge with `--merge` and for release-please to tag it.
+3. Check back-merge folded `main` into `next`.
+4. Only then, with the maintainer's explicit go: `gh api -X PUT repos/ai-driven-dev/framework/rulesets/12902947 --input .github/rulesets/main.json`.
+5. Read back `allowed_merge_methods` from the live ruleset.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | T1 and T2 fail on the current files for the reason they name |
+| 2 | T2 and T3 pass. Restoring `--squash` turns T3 red |
+| 3 | T1 passes, and `.github/rulesets/main.json` stays valid JSON |
+| 4 | `pnpm exec lefthook run pre-commit` is green |
+| 5 | A release PR merged with `--merge` is tagged, back-merge succeeds, and the live ruleset reads `["merge"]` |
diff --git a/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/plan.md b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/plan.md
new file mode 100644
index 000000000..1a25d3d60
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_23_credit-contributors-release-notes/plan.md
@@ -0,0 +1,60 @@
+---
+objective: "Every GitHub release credits each changelog line's commit author by @handle, and main accepts only merge commits so a promote keeps every commit and its author."
+status: pending
+---
+
+# Plan: Credit contributors in release notes
+
+## Overview
+
+| Field | Value |
+| --- | --- |
+| **Goal** | Post-process each GitHub release to append the commit author's `@handle` per line, and restrict `main` to merge commits |
+| **Source** | [#911](https://github.com/ai-driven-dev/framework/issues/911), [`brainstorm.md`](./brainstorm.md) |
+
+## Phases
+
+| # | Phase | File |
+| --- | --- | --- |
+| 1 | Credit commit authors in GitHub releases | [`phase-1.md`](./phase-1.md) |
+| 2 | Restrict main to merge commits | [`phase-2.md`](./phase-2.md) |
+
+## Resources
+
+| Source | Verified |
+| --- | --- |
+| Sandbox repo, release-please 17.11.2 CLI, `include-commit-authors: true` | the generated notes carried no author at all |
+| `release-please` 17.11.2 `build/src/commit.js` `parseConventionalCommits` | the parsed commit drops `author`, so `default.js` never sees one. Upstream [#2761](https://github.com/googleapis/release-please/issues/2761), fix [#2892](https://github.com/googleapis/release-please/pull/2892) open, 17.11.2 is the latest release |
+| `release-please` `build/src/changelog-notes/github.js` | `changelog-type: github` ignores commits and reads the whole tag range, which is wrong for component releases |
+| Prototype run on the sandbox's releases, then a second run | every line gained `(@login)`; the second run changed nothing |
+| Prototype dry run over `v5.10.0`'s real notes, read-only | all 44 lines credited: 33 `@blafourcade`, 7 `@dependabot[bot]`, 1 `@alexsoyes`, including lines ending in `, closes #…` |
+| `gh pr list --base next` (60 PRs) and `gh api commits/` | a squash commit's author is the PR author (55/55 humans), even when someone else merges |
+| `back-merge.yml` `on:` | triggers on `release: published` only. Editing a release fires `edited`, so crediting does not re-run the back-merge |
+| Sandbox, ruleset `allowed_merge_methods: ["merge"]` | squash and rebase refused, merge accepted |
+| Sandbox, bypass actor in `pull_request` mode (the live mode for `aidd-bot` and `admin`) | squash still refused, even with `--admin`. Only `always` mode allowed it |
+| Sandbox, release PR under that ruleset | `--squash --admin` refused; `--merge --admin` merged and release-please tagged all three releases on the merge commit |
+| Sandbox, back-merge `--no-ff` then a second cycle | no conflict; the next release carried only the new line, only for the touched component |
+| Sandbox, squashed promote with the ruleset disabled | release-please logged "No user facing commits found": the week's entries vanished silently |
+| `.../rulesets/12902947` | live `main protection` carries `allowed_merge_methods: [merge, squash, rebase]`, absent from `.github/rulesets/main.json`; nothing syncs the two |
+| PR #135, commit `1919c7cd` | this repository already released from a merge-commit release PR (`v4.1.0`) |
+| `@commitlint/is-ignored` `lib/defaults.js` | `Merge pull request …` subjects are ignored, so a merge commit on `main`'s tip passes commitlint |
+
+## Decisions
+
+| Decision | Why |
+| --- | --- |
+| Post-process the GitHub releases, not the native option | the native option is a no-op until upstream #2892 ships. The step carries a comment linking #2761 and #2892, and #911 records the follow-up: remove it once a fixed release-please is pinned |
+| Resolve the author from each line's commit SHA, not its `#N` | the SHA is the commit release-please credited; a line can also carry `closes #N`. Matches the committer semantics agreed in brainstorm |
+| No login resolves: append the author's name without `@` | same fallback as the native option |
+| A line already ending in `(@…)` or a credited name is left alone | re-running the job never duplicates |
+| Only the GitHub releases carry handles | `CHANGELOG.md` and the release PR body are written before the step runs. Accepted |
+| `main` allows only merge commits | a ruleset cannot target a head branch. In `pull_request` bypass mode nobody, admins included, can squash or rebase a promote |
+| The release PR merges with `--merge --admin` | `--squash --admin` is refused under the ruleset. `--admin` stays for approvals |
+| Rollout order: the `--merge` release merge ships and proves itself before the live ruleset changes | applied first, the ruleset would block the next release |
+| No post-merge net | the ruleset already blocks every actor in `pull_request` mode; only turning a bypass to `always` or disabling the ruleset reopens the gap |
+| A `hotfix/*` lands as a merge commit | the one human flow that changes. Its subject is ignored by commitlint, and release-please reads its conventional commits |
+
+Tradeoffs the user accepts:
+
+- A `@login` in nine release bodies may notify each contributor every cycle. Not verified.
+- `(@dependabot[bot])` appears on dependency lines.
diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/backlog-link.json b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/backlog-link.json
new file mode 100644
index 000000000..53c06a37d
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/backlog-link.json
@@ -0,0 +1,5 @@
+{
+ "backlog": "ai-driven-dev/framework#908",
+ "written_at": "2026-09-24T00:00:00Z",
+ "written_by": "aidd-dev:01-plan"
+}
diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-1.md
new file mode 100644
index 000000000..7a3cd3cbe
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-1.md
@@ -0,0 +1,91 @@
+---
+status: pending
+---
+
+# Instruction: Scaffold `aidd-qa` with the acceptance QA skill
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+plugins/aidd-qa/
+├── .claude-plugin/plugin.json ✅ name, version 0.1.0, description, skills[]
+├── README.md ✅ concern, skill table, browser-only scope, install line
+└── skills/01-acceptance-qa/
+ ├── SKILL.md ✅ router: prerequisites → load-scope → prepare-run → run-scenarios
+ ├── actions/00-prerequisites.md ✅ moved from aidd-dev, unchanged behavior
+ ├── actions/01-load-scope.md ✅ rewritten: scenarios derive from acceptance criteria only
+ ├── actions/02-prepare-run.md ✅ moved, deterministic setup and teardown kept
+ ├── actions/03-run-scenarios.md ✅ moved, report fields expected/actual/verdict/evidence
+ ├── assets/qa-report-template.md ✅ one row per scenario: expected, actual, verdict, evidence
+ └── references/interface-browser-playwright-cli.md ✅ moved recording contract, names browser as the supported interface
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[Acceptance criteria + reviewed candidate] --> B[prerequisites]
+ B --> C[load-scope: one scenario per browser-observable criterion]
+ C --> D[prepare-run: auth, fixtures, teardown]
+ D --> E[run-scenarios: record, verdict, reset]
+ E --> F[qa.md + qa/*.webm]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ copy the plugin into a fresh marketplace checkout => plugin directory present: 5: system
+ section Happy path
+ run check-architecture-rules on plugins/aidd-qa => no violation: 5: cli
+ section Edge case - criterion not browser-observable
+ load-scope given a criterion with no browser outcome => criterion listed as out of interface, no scenario: 5: system
+```
+
+## Tasks to do
+
+### `1)` Manifest and README
+
+> The plugin declares itself and one skill.
+
+1. `plugin.json` from `aidd-ui`'s shape: `name: aidd-qa`, `version: 0.1.0`, description "Acceptance QA: validates observable behavior against acceptance criteria and records reviewer evidence. Use when … Do NOT use for …", `skills: ["./skills/01-acceptance-qa"]`, keywords.
+2. `README.md`: concern, skill table, browser as the only interface today, `/plugin install aidd-qa@aidd-framework`. No sibling-plugin address.
+
+### `2)` Move the skill
+
+> Browser QA lives under `aidd-qa` with history kept.
+
+1. `git mv plugins/aidd-dev/skills/11-browser-qa plugins/aidd-qa/skills/01-acceptance-qa` (phase 2 recreates the redirect in `aidd-dev`).
+2. Rename `references/run-scope-playwright-cli.md` to `references/interface-browser-playwright-cli.md`; update the link in `03-run-scenarios.md`.
+3. `SKILL.md`: frontmatter `name: 01-acceptance-qa`, description stating input = acceptance criteria + reviewed candidate, browser interface; router table and transversal rules kept; add rule "never derive a scenario from the diff or the source code".
+
+### `3)` Acceptance-derived scope
+
+> Scenarios come from acceptance criteria, not from implementation.
+
+1. `01-load-scope` Input: acceptance criteria (issue, spec, plan) + reference to the reviewed candidate (branch, commit, or running URL).
+2. Process: one happy path from the criteria's primary journey, edge cases only from criteria or the plan's browser Test Scope; each scenario keeps the criterion it proves; a criterion with no browser-observable outcome is listed as out of interface, never tested by reading code.
+3. Remove steps that source edge cases from the implementation artifact or related tests.
+4. Add a `## Test` section to every action that lacks one.
+
+### `4)` Report per scenario
+
+> Each scenario states expected, actual, verdict, evidence.
+
+1. `qa-report-template.md`: header verdict, source (acceptance criteria path), candidate, run date; table `Scenario | Criterion | Expected | Actual | Verdict | Evidence`.
+2. `03-run-scenarios` Report step and Test reference those fields; video validation steps kept.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | `plugin.json` validates against its schema; no `aidd-:` of another plugin in README |
+| 2 | `plugins/aidd-qa/skills/01-acceptance-qa/` holds 4 actions, 1 asset, 1 reference; `check-architecture-rules.js` passes |
+| 3 | `01-load-scope` names acceptance criteria as its only scenario source and forbids diff or source-derived scenarios |
+| 4 | the report template has Expected, Actual, Verdict, Evidence columns; video checks (codec, dimension, duration, frames) still present |
diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-2.md
new file mode 100644
index 000000000..9c22452b1
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-2.md
@@ -0,0 +1,66 @@
+---
+status: pending
+---
+
+# Instruction: Retire `aidd-dev:11-browser-qa` to a redirect
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+plugins/aidd-dev/
+├── .claude-plugin/plugin.json ✏️ description drops Browser QA; skills[] keeps ./skills/11-browser-qa
+├── README.md ✏️ Browser QA row becomes a moved note
+└── skills/11-browser-qa/
+ ├── SKILL.md ✅ retired redirect router, one action
+ └── actions/01-redirect.md ✅ prints the migration message and stops
+aidd_docs/memory/testing.md ✏️ Browser QA owner is aidd-qa
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[User invokes the old Browser QA skill] --> B[redirect action]
+ B --> C[Message: moved to the aidd-qa plugin + install command]
+ C --> D[Stop, no QA run]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Happy path
+ invoke the retired skill => migration message naming aidd-qa and its install command: 5: system
+ section Edge case - description matching
+ ask for browser QA with both plugins installed => description declares itself retired and not for running QA: 5: system
+```
+
+## Tasks to do
+
+### `1)` Redirect skill
+
+> The old invocation answers with a migration message.
+
+1. `SKILL.md`: `name: 11-browser-qa`, description "Retired. Explains where browser QA moved. Use only when this skill is invoked by name. Do NOT use to run QA or record evidence." Actions table with `redirect`.
+2. `actions/01-redirect.md`: Input none; Output the message "Browser QA moved to the `aidd-qa` plugin. Install it with `/plugin install aidd-qa@aidd-framework` (or `aidd plugin install aidd-qa`) and run its acceptance QA skill."; Process print and stop, never run QA; `## Test`.
+3. No `aidd-:` token anywhere in the redirect.
+
+### `2)` aidd-dev surface
+
+> aidd-dev no longer claims Browser QA.
+
+1. `plugin.json` description: remove "plus short standalone Browser QA evidence".
+2. `README.md`: remove Browser QA from the covers sentence; row 2.11 says retired, moved to the `aidd-qa` plugin.
+3. `aidd_docs/memory/testing.md`: owner `aidd-qa`, skill `01-acceptance-qa`.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | the redirect holds no QA process; `check-architecture-rules.js` passes on it |
+| 2 | `grep -i "browser qa" plugins/aidd-dev/.claude-plugin/plugin.json` finds nothing; memory names `aidd-qa` as owner |
diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-3.md b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-3.md
new file mode 100644
index 000000000..cb03aef56
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-3.md
@@ -0,0 +1,70 @@
+---
+status: pending
+---
+
+# Instruction: Register the plugin and update the docs
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+.claude-plugin/marketplace.json ✏️ aidd-qa entry, strict, metadata.recommended false
+release-please-config.json ✏️ plugins/aidd-qa package
+.release-please-manifest.json ✏️ "plugins/aidd-qa": "0.1.0"
+.github/workflows/ci.yml ✏️ build-plugin matrix gains aidd-qa
+commitlint.config.cjs ✏️ scope-enum gains aidd-qa, qa
+docs/ARCHITECTURE.md ✏️ concerns table row: aidd-qa, Acceptance QA, Execution + status note
+docs/CATALOG.md ✏️ aidd-qa section; Browser QA row removed from aidd-dev
+README.md ✏️ plugin counts, aidd-qa section, aidd-dev line drops Browser QA
+aidd_docs/memory/architecture.md ✏️ 9 plugins, 3 off the curated path
+aidd_docs/memory/project-brief.md ✏️ key feature row for acceptance QA
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[marketplace.json lists aidd-qa] --> B[release-please versions it]
+ B --> C[ci.yml builds its archive]
+ A --> D[docs and taxonomy name it]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Happy path
+ run the scripts suite => release-covers-every-plugin and architecture-doc tests pass: 5: cli
+ section Edge case - curated install
+ read marketplace.json => aidd-qa has recommended false: 5: system
+```
+
+## Tasks to do
+
+### `1)` Registration
+
+> Every release and CI guard sees the plugin.
+
+1. Marketplace entry after `aidd-telemetry`, description by concern, `recommended: false`.
+2. release-please package block copied from `plugins/aidd-ui`; manifest `0.1.0`.
+3. `ci.yml` matrix and commitlint scopes.
+
+### `2)` Docs and memory
+
+> Every place that counts or lists plugins stays true.
+
+1. `docs/ARCHITECTURE.md` row + a one-line status note (off the curated path until proven).
+2. `docs/CATALOG.md` and `README.md`: add aidd-qa, fix counts and the aidd-dev description, keep the README badge/status style used for `aidd-ui`/`aidd-telemetry`.
+3. Memory `architecture.md` gotcha count, `project-brief.md` feature row.
+4. Let `pnpm exec lefthook run pre-commit` regenerate catalogs and counts; never hand-edit generated files.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | `release-covers-every-plugin.test.js` passes; marketplace JSON validates |
+| 2 | `architecture-doc-matches-the-tree.test.js` passes; no doc still says 8 plugins or attributes Browser QA to aidd-dev |
diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-4.md b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-4.md
new file mode 100644
index 000000000..0aec832d1
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/phase-4.md
@@ -0,0 +1,62 @@
+---
+status: pending
+---
+
+# Instruction: Prove it installs and translates to a second host
+
+## Architecture projection
+
+> Tree of the final files. ✅ create · ✏️ modify · ❌ delete
+
+```txt
+aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/validation.md ✅ commands run and their decisive output
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[Gates] --> B[claude plugin validate plugins/aidd-qa]
+ B --> C[cli build + aidd translate to Codex]
+ C --> D[validation.md]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ build the CLI => dist/cli.js present: 5: cli
+ section Happy path
+ translate the marketplace to codex into the scratchpad => aidd-qa skill, actions, asset, reference present: 5: cli
+ section Edge case - Claude Code manifest
+ claude plugin validate plugins/aidd-qa => valid: 5: cli
+```
+
+## Tasks to do
+
+### `1)` Gates
+
+> Every repository gate is green.
+
+1. `pnpm exec lefthook run pre-commit`.
+2. `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'`.
+3. `pnpm test:changed`.
+
+### `2)` Host proof
+
+> The plugin works in Claude Code and Codex.
+
+1. `claude plugin validate plugins/aidd-qa` (and the marketplace root).
+2. `cd cli && pnpm install && pnpm build`; read `node cli/dist/cli.js translate --help`; translate to Codex into the scratchpad; list the aidd-qa output.
+3. Record commands and decisive lines in `validation.md`.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| --- | --- |
+| 1 | all three commands exit 0 |
+| 2 | Claude Code validation passes and the Codex output contains the acceptance QA skill with its four actions |
diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/plan.md b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/plan.md
new file mode 100644
index 000000000..19987b003
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/plan.md
@@ -0,0 +1,46 @@
+---
+objective: "An installable, off-curated-path aidd-qa plugin owns browser acceptance QA derived from acceptance criteria, and aidd-dev no longer carries browser QA."
+status: implemented
+---
+
+# Plan: aidd-qa plugin
+
+## Overview
+
+| Field | Value |
+| --- | --- |
+| **Goal** | Create `aidd-qa`, move Browser QA into it as acceptance QA, remove the `aidd-dev` skill, register the plugin everywhere a plugin is registered |
+| **Source** | https://github.com/ai-driven-dev/framework/issues/908 |
+
+## Phases
+
+| # | Phase | File |
+| --- | --- | --- |
+| 1 | Scaffold `aidd-qa` with the acceptance QA skill | [`phase-1.md`](./phase-1.md) |
+| 2 | Retire `aidd-dev:11-browser-qa` to a redirect | [`phase-2.md`](./phase-2.md) |
+| 3 | Register the plugin and update the docs | [`phase-3.md`](./phase-3.md) |
+| 4 | Prove it installs and translates to a second host | [`phase-4.md`](./phase-4.md) |
+
+## Resources
+
+| Source | Verified |
+| --- | --- |
+| `docs/CREATE_PLUGIN.md` | registration = marketplace entry with `metadata.recommended: false`, release-please config and manifest; no cross-plugin reference in descriptions or READMEs |
+| `docs/ARCHITECTURE.md` + `scripts/__tests__/architecture-doc-matches-the-tree.test.js` | the concerns table must hold one row per plugin in the tree |
+| `scripts/__tests__/release-covers-every-plugin.test.js` | `ci.yml` `build-plugin` matrix and `release-please-config.json` must list every marketplace plugin |
+| `scripts/lib/architecture-rules.js` (`PLUGIN_ADDRESS`) | any `aidd-:` token (optional `/` or `@`) in a skill, action, reference or agent of another plugin is an orthogonality violation; a bare plugin name or `aidd-qa@aidd-framework` is not |
+| commit 627408fb (aidd-telemetry added) | touchpoints for a new plugin: marketplace, release manifest, README, memory; `.claude/settings.json` enables only curated plugins |
+| PR #512 (Browser QA landed) | `aidd-vcs` pull-request draft links `**/qa/*.webm`; keeping the `qa/` evidence folder name keeps that link working |
+
+## Decisions
+
+| Decision | Why |
+| --- | --- |
+| Skill `aidd-qa:01-acceptance-qa`, browser as its only interface, declared in an interface reference | the entry point is named by intention (acceptance validation), so API or CLI interfaces can be added later without renaming; none is claimed now |
+| Layer Execution in the taxonomy | it drives the running application, which the Knowledge firewall forbids |
+| `aidd-dev:11-browser-qa` is removed after a first redirect iteration | `aidd-dev` owns no QA surface; `aidd-qa` is the single owner |
+| `aidd-dev:06-test` `test-journey` stays in `aidd-dev` | it is developer-side validation the SDLC Deliver zone runs before commit, not independent acceptance evidence; moving it is outside #908 |
+| Evidence folder stays `qa/` with `happy-path.webm` and `edge-case-.webm` | the pull-request draft already links `**/qa/*.webm` |
+| First release tagged `aidd-qa-v1.0.0` via `release-as` on the package, manifest at `0.1.0` | a `Release-As` footer on the squash commit would also re-version every other path it touches; drop `release-as` once `v1.0.0` ships |
+| Not added to `.claude/settings.json` `enabledPlugins` | that list holds only curated plugins; `aidd-ui` and `aidd-telemetry` are absent too |
+| Commits split by path | release-please bumps per path from the commit type |
diff --git a/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/validation.md b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/validation.md
new file mode 100644
index 000000000..2f361f8ee
--- /dev/null
+++ b/aidd_docs/tasks/2026_09/2026_09_24_aidd-qa-plugin/validation.md
@@ -0,0 +1,152 @@
+---
+status: done
+---
+
+# Validation: aidd-qa plugin
+
+Commands run from the repository root unless noted, with their decisive output line. The original "Gates", "Host proof", and "Architecture conformance" tables below were all run against that implementation's own final working tree, before it was split into its first five commits — not re-run per commit. Each commit's own `pre-commit` hook run exited 0 (a non-zero exit would have aborted the commit), but that hook reads the working tree at commit time, not a diff scoped to that commit's own files; see "Commits" below for what that means for the two intermediate trees. "Repair re-run", further down, records a separate, later run against this repair's own final tree — read its own heading for which tree it covers, not this paragraph.
+
+Review #908 found five defects after the push recorded in the original "Push" section: a missing run-verdict rule in `03-run-scenarios.md`, a `load-scope` that never stopped early when no criterion is browser-observable, a duplicate `## Test` bullet, two hand-written docs still describing `06-test` as generic test coverage after `ba2a59a5` narrowed it, and this record's own commit table and push section going stale as later commits (`ba2a59a5`, `e8d3538e`, `cff70e66`) landed without an update. Fixing the second defect had a second-pass bug: the first attempt at a `load-scope`-skips-`prerequisites` rule left the router's own action order (`00-prerequisites` before `01-load-scope`) unchanged, so the rule was aspirational rather than true; a second pass moved the criteria check ahead of `00` in `SKILL.md` itself. The docs-wording fix for defect four also had a first-pass bug: it copied this task's own writing constraint ("no sibling-plugin address") into the README and CATALOG rows as if it described the skill, corrected once caught. Both are visible as separate commits below rather than folded into the commits that introduced them, since those commits were already pushed.
+
+## Gates (phase 4, task 1)
+
+| # | Command | Exit | Decisive output |
+| --- | --- | --- | --- |
+| 1 | `pnpm exec lefthook run pre-commit` (final clean sweep, all task files staged) | 0 | `summary: (done in 48.22 seconds)` — `check-skill-argument-hints`, `cli-architecture`, `doc-duplication`, `markdown-links`, `referenced-paths`, `scripts-tests`, `summarize-plugin-catalogs`, `summarize-telemetry-prompts-doc`, `sync-readme-counts` all `✔️`; embedded `scripts-tests` run: `ℹ tests 503` / `ℹ pass 503` / `ℹ fail 0` |
+| 2 | `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` | 0 | `ℹ tests 503` / `ℹ pass 501` / `ℹ fail 0` / `ℹ skipped 2` — the 2 skips are named `hands a broken Biome rule back to the agent, naming it` and `formats a file in place and stays silent on what would not block a commit` (`# SKIP`), both pre-existing Biome-gated CLI tests, unrelated to this change |
+| 3 | `pnpm test:changed` | 0 | every project block reports `ℹ fail 0`; final combined run `ℹ fail 0` |
+
+`pre-commit`'s glob-scoped jobs (`architecture-rules`, `json-validity`, `yaml-validity`, `skill-frontmatter`) reported "no files for inspection" on the staged-file glob lefthook resolved in this checkout, so they did not execute inside that run. Ran explicitly instead: `pnpm exec lefthook run pre-commit --all-files --job architecture-rules --job json-validity --job yaml-validity --job skill-frontmatter` → exit 0, `✅ Architecture rules: 431 governed file(s) checked, no violation`, `JSON validation passed for 79 file(s).`, `YAML validation passed for 22 file(s).`, `skill-frontmatter` ✔️ with no breach printed.
+
+Root `pnpm install` and `cd cli && pnpm install` were run first — neither `node_modules` existed in this worktree, so `js-yaml` (root) and `vitest` (`cli`) were missing and `cli-architecture` failed with `vitest: command not found` (exit 127) until installed. Not a regression from this change; recorded because it would otherwise have looked like an empty-gate false pass.
+
+One scripts-suite regression was found and fixed as part of this change: `scripts/__tests__/architecture-rules.test.js` pins `skillsWithActions` to the number of skills with an `actions/` dir, swept from the real `plugins/` tree. Adding `aidd-qa:01-acceptance-qa` raises that from 48 to 49; the assertion was updated to `49` (test intent unchanged — it still fails if a sweep silently misses a skill).
+
+### Repair re-run (review #908 follow-up)
+
+Each command below was run with its exit code captured directly (`cmd > log 2>&1; echo EXIT=$?`), not through a wrapper that could mask it. This is the last of two re-runs during this repair; the first (after the three initial fixes, before the `SKILL.md` routing and docs-wording corrections) produced the same decisive numbers, so only this final one, against this repair's actual final tree (`docs/CATALOG.md`, `plugins/aidd-dev/README.md`, and `plugins/aidd-qa/skills/01-acceptance-qa/{SKILL.md,actions/01-load-scope.md,actions/03-run-scenarios.md}` staged together), is recorded:
+
+| # | Command | Exit | Decisive output |
+| --- | --- | --- | --- |
+| 1 | `pnpm exec lefthook run pre-commit` | 0 | `summary: (done in 48.73 seconds)` — `check-skill-argument-hints`, `doc-duplication`, `markdown-links`, `referenced-paths`, `scripts-tests`, `summarize-plugin-catalogs`, `summarize-telemetry-prompts-doc`, `sync-readme-counts` all `✔️`; embedded `scripts-tests` run: `ℹ tests 503` / `ℹ pass 503` / `ℹ fail 0` / `ℹ skipped 0` |
+| 2 | `node scripts/check-architecture-rules.js` (no args, whole governed tree) | 0 | `✅ Architecture rules: 352 governed file(s) checked, no violation` |
+| 3 | `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` | 0 | `ℹ tests 503` / `ℹ suites 22` / `ℹ pass 503` / `ℹ fail 0` / `ℹ skipped 0` |
+| 4 | `claude plugin validate plugins/aidd-qa` | 0 | `✔ Validation passed` |
+
+Both scripts-suite entries in this re-run agree on one number, 503 tests / 503 pass / 0 fail / 0 skipped — the earlier 503-pass-vs-501-pass-plus-2-skip split recorded above (gates 1 and 2, phase 4) no longer reproduces on this tree. `architecture-rules`, `json-validity`, `skill-frontmatter`, and `yaml-validity` again reported "no files for inspection" against lefthook's staged-file glob in this checkout, the same quirk noted above; `check-architecture-rules.js` was run explicitly instead, as this task's dispatch required, rather than via `--all-files --job`. `summarize-plugin-catalogs` and `sync-readme-counts` ran but changed nothing — `git status` after each pre-commit run showed no file beyond the ones already staged.
+
+### Second repair re-run (review #908, second follow-up)
+
+Commands below were run with their exit code captured directly (`cmd > log 2>&1; echo EXIT=$?`), against this round's tree with commit 15 (`SKILL.md`, `01-load-scope.md`, `02-prepare-run.md`, `03-run-scenarios.md`, `qa-report-template.md`) already landed and this file staged alone for commit 16:
+
+| # | Command | Exit | Decisive output |
+| --- | --- | --- | --- |
+| 1 | `pnpm exec lefthook run pre-commit` | 0 | `summary: (done in 3.27 seconds)` — `doc-duplication`, `markdown-links`, `referenced-paths`, `summarize-plugin-catalogs`, `summarize-telemetry-prompts-doc`, `sync-readme-counts` all `✔️`; `architecture-rules`, `check-skill-argument-hints`, `json-validity`, `scripts-tests`, `skill-frontmatter`, `yaml-validity` skipped — this round's only staged file for this run is this task doc, which matches none of their globs |
+| 2 | `node scripts/check-architecture-rules.js` (no args, whole governed tree) | 0 | `✅ Architecture rules: 352 governed file(s) checked, no violation` |
+| 3 | `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` | 0 | `ℹ tests 503` / `ℹ suites 22` / `ℹ pass 503` / `ℹ fail 0` / `ℹ skipped 0` |
+| 4 | `claude plugin validate plugins/aidd-qa` | 0 | `✔ Validation passed` |
+
+`git status` after the pre-commit run showed no file beyond `validation.md`, already staged — `summarize-plugin-catalogs` and `sync-readme-counts` changed nothing, since commit 15 touched no `CATALOG.md`-governed surface.
+
+## Host proof (phase 4, task 2)
+
+| # | Command | Exit | Decisive output |
+| --- | --- | --- | --- |
+| 1 | `claude plugin validate plugins/aidd-qa` | 0 | `✔ Validation passed` |
+| 2 | `claude plugin validate plugins/aidd-dev` | 0 | `✔ Validation passed` (redirect skill included) |
+| 3 | `claude plugin validate .` | 0 | `✔ Validation passed` (marketplace root, 9 plugins) |
+| 4 | `cd cli && pnpm install && pnpm build` | 0 | `Bundle size: 727.1 KB / budget: 734 KB` / `OK: within budget` |
+| 5 | `node cli/dist/cli.js translate --help` | 0 | `--to Conversion target (claude, cursor, copilot, codex, opencode, kilo)` |
+| 6 | `node cli/dist/cli.js translate . --to codex --out /translate-codex --as marketplace` | 0 | `Built 9 plugins, 477 files written to /translate-codex` |
+| 7 | `HOME=/install-sandbox/home node cli/dist/cli.js setup --source local --path --ai claude,codex --plugins none --yes --scope project` (run from an empty `/install-sandbox/project`, criterion 10) | 0 | `Installed claude, codex (2 files)` |
+| 8 | `HOME=/install-sandbox/home node cli/dist/cli.js plugin install aidd-qa --tool claude --scope project --yes` then the same with `--tool codex` (criterion 10) | 0 (both) | `Warning: Native plugin activation — upgrade marketplace 'aidd-framework' skipped: codex marketplace upgrade aidd-framework failed: Error: marketplace aidd-framework is not configured as a Git marketplace` then `Installed 'aidd-qa'.` (both tools; the warning is expected — the sandbox's marketplace source is local, not Git) |
+
+Gate 8's install landed the full skill surface under both tools' caches — `find /install-sandbox/home/.claude/plugins/cache/aidd-framework/aidd-qa/0.1.0/skills/01-acceptance-qa -name '*.md'` lists `SKILL.md` plus its 4 actions, 1 asset, 1 reference, matching the phase-1 projection and gate 6's Codex translate listing; `diff` against the working tree's own `SKILL.md` at commit 15's tree reported no difference, so the installed copy carries this round's Rejected-section and skip-destination fix, not a stale cached one.
+
+Translated output confirms the full skill surface reached Codex:
+
+```
+plugins/aidd-qa/.codex-plugin/plugin.json
+plugins/aidd-qa/skills/01-acceptance-qa/SKILL.md
+plugins/aidd-qa/skills/01-acceptance-qa/actions/00-prerequisites.md
+plugins/aidd-qa/skills/01-acceptance-qa/actions/01-load-scope.md
+plugins/aidd-qa/skills/01-acceptance-qa/actions/02-prepare-run.md
+plugins/aidd-qa/skills/01-acceptance-qa/actions/03-run-scenarios.md
+plugins/aidd-qa/skills/01-acceptance-qa/assets/qa-report-template.md
+plugins/aidd-qa/skills/01-acceptance-qa/references/interface-browser-playwright-cli.md
+```
+
+4 actions, 1 asset, 1 reference — matches the phase-1 architecture projection.
+
+## Architecture conformance
+
+| Command | Exit | Decisive output |
+| --- | --- | --- |
+| `node scripts/check-architecture-rules.js $(find plugins/*/skills plugins/*/agents -name '*.md')` | 0 | `✅ Architecture rules: 433 governed file(s) checked, no violation` |
+| `node --test scripts/__tests__/architecture-doc-matches-the-tree.test.js` | 0 | `the plugin concerns table has one row per plugin in the tree` passes |
+| `node --test scripts/__tests__/release-covers-every-plugin.test.js` | 0 | `build-plugin builds an archive for every plugin the marketplace lists` and `release-please versions every plugin the marketplace lists` both pass |
+| `node scripts/check-doc-duplication.js` | 0 | `✅ Doc duplication: 0 duplicated sentence(s) in 48 files` |
+| `node scripts/check-referenced-paths.js` | 0 | `✅ Referenced paths: 0 dead in 34 files` |
+| `node scripts/check-skill-argument-hints.mjs` | 0 | `Every skill names what the user brings.` |
+| `node scripts/check-markdown-links.js --ignore cli/tests/fixtures --ignore cli/aidd_docs/tasks` | 0 | `✅ Links: 0 broken in 791 files` |
+
+`/aidd-dev:03-assert`'s `assert-architecture` facet (report-only): no macro violation — `plugins/aidd-qa/` matches the documented plugin anatomy (`.claude-plugin/plugin.json` + `skills/01-acceptance-qa/{SKILL.md, actions/, assets/, references/}`, no unused optional surfaces); no micro violation — the skill's action files carry no cross-plugin address. `assert-frontend` was skipped: this change ships markdown only, no running UI to drive.
+
+No `aidd-:` token for another plugin appears in `plugins/aidd-qa/**` or `plugins/aidd-dev/skills/11-browser-qa/**`; the redirect names only the bare `aidd-qa` plugin and `/plugin install aidd-qa@aidd-framework` (no colon after `aidd-qa`, so `PLUGIN_ADDRESS` does not match it).
+
+## Commits
+
+All commits on this branch (`git log origin/next..HEAD`), oldest first. Rows 1-8 were pushed before the first repair started (`cff70e66` confirmed reaching `origin` at that push, original "Push" section below). Rows 9-14 are that first repair's, each carrying its real local SHA `git rev-parse --short=8 HEAD` printed right after its own `git commit`, captured into this table before the next commit ran; row 14's SHA (`3c81dfb7`) is filled in now that it is known, though the commit that first wrote this row could not yet state it. Rows 15-16 are a second round, driven by this review's follow-up findings on load-scope's skip destination, run-scenarios' verdict ordering for a rejected scenario set, and this record's own defect count and commit table; row 16, this file's own commit, cannot state its own SHA — read it off `git log` or `git ls-remote` on the pushed branch.
+
+| # | SHA | Subject |
+| --- | --- | --- |
+| 1 | `3ef728a7` | `feat(aidd-qa): scaffold acceptance QA plugin from browser QA` |
+| 2 | `ffb9c9c5` | `feat(aidd-dev): retire browser-qa to a redirect` |
+| 3 | `50c13400` | `chore(marketplace): register aidd-qa plugin` |
+| 4 | `f5c3dfcb` | `docs(aidd-qa): add the plan and its validation record` |
+| 5 | `cd048e6b` | `docs(aidd-qa): correct the validation record` |
+| 6 | `ba2a59a5` | `fix(aidd-dev): scope 06-test to developer-side validation` |
+| 7 | `e8d3538e` | `fix(aidd-qa): restrict criteria sourcing and complete the report contract` |
+| 8 | `cff70e66` | `docs: correct plugin count and the pushed validation record` |
+| 9 | `89243332` | `fix(aidd-qa): add run-verdict rule and stop load-scope early` — `03-run-scenarios.md`, `01-load-scope.md`, `SKILL.md` |
+| 10 | `db49deaa` | `fix(aidd-dev): describe 06-test as developer-side in its README` |
+| 11 | `b642829f` | `docs: correct 06-test's catalog description and the validation record` — `docs/CATALOG.md`, this file (an earlier draft of it) |
+| 12 | `911bcab5` | `fix(aidd-qa): route the zero-criterion skip before prerequisites` — `SKILL.md`, correcting the routing bug commit 9's transversal rule left in place, and correcting commit 9's own commit-body claim about where `load-scope` stops |
+| 13 | `a6e0cda4` | `fix(aidd-dev): drop the addressing note from the 06-test README row` — correcting a writing-constraint phrase commit 10 copied into product-facing text |
+| 14 | `3c81dfb7` | `docs: correct 06-test's catalog description and the validation record` — `docs/CATALOG.md`, this file |
+| 15 | `ec1cf16f` | `fix(aidd-qa): report a fully rejected scenario set as blocked, not skipped` — `SKILL.md`, `01-load-scope.md`, `02-prepare-run.md`, `03-run-scenarios.md`, `qa-report-template.md`, correcting review #908's follow-up warning about a scenario set emptied by rejection |
+| 16 | (this round's final docs commit — this record) | `docs: correct the validation record's defect count and commit table, add install proof` — this file |
+
+`summarize-plugin-catalogs` and `sync-readme-counts` regenerate `plugins/*/CATALOG.md` and README's counts block from the live working tree, not from the commit's own staged diff — the working tree already held the final content when commit 1 ran, so `plugins/aidd-dev/CATALOG.md` in commit 1 already describes the redirect that only lands in commit 2. That is a known, accepted side effect; it does not change what either commit's hand-authored content says. It also means the two intermediate trees are not independently clean against the gates in this file:
+
+- **At commit 1:** `plugins/aidd-dev/.claude-plugin/plugin.json` still lists `"./skills/11-browser-qa"` in `skills[]`, but that tree has no `plugins/aidd-dev/skills/11-browser-qa/` directory (it moved to `aidd-qa` in this same commit, and the redirect is not added until commit 2). `scripts/__tests__/architecture-rules.test.js`'s `skillsWithActions` sweep would read `48` on this tree, not the `49` the pinned assertion (also changed in commit 1) expects — the pin only becomes true at commit 2, once the redirect's own `actions/` directory exists. This was a mistake in how the pin's commit placement was chosen, caught only while writing this correction, not fixed by rewriting unpushed history.
+- **At commit 1 and 2:** no `aidd-qa` entry exists yet in `.claude-plugin/marketplace.json`, so `release-covers-every-plugin.test.js` and `architecture-doc-matches-the-tree.test.js`'s concerns-table check would fail on those trees in isolation (9 plugin directories, 8-row concerns table / 8-plugin marketplace).
+
+The commits were not restructured to fix this: the tree every gate in the original "Gates", "Host proof", and "Architecture conformance" tables above was actually run against is that implementation's final one (after commit 3), and splitting further would move the same CATALOG-regeneration mismatch somewhere else rather than remove it. The same applies to the first repair's own six commits (9-14): "Repair re-run" above was run once, against that repair's final tree, not per commit. The second round's own two commits (15-16) were checked the same way; see "Second repair re-run" below.
+
+## Push
+
+`git push -u origin feat/aidd-qa-plugin` first failed on `pnpm exec lefthook run pre-push` (glob `cli/**` — see below for why it ran), at `cli-test`, before any network call:
+
+| Job | Result |
+| --- | --- |
+| `cli-knip` | ✔️ |
+| `cli-test` (`pnpm --dir cli test`) | ✖ `Test Files 1 failed \| 528 passed \| 1 skipped (530)` / `Tests 1 failed \| 6794 passed \| 1 skipped (6796)`, decisive line: `tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts:51 AssertionError: expected '' not to be ''` |
+
+The failing assertion is `E2E: the sandbox a test spawns into > still reaches node and git, which the code under test genuinely needs`; under this test's synthetic sandboxed `PATH`, `which node` returns nothing. Isolated it and confirmed:
+
+- Root cause, verified rather than guessed: `ls "$(dirname "$(node -p 'process.execPath')")" | grep -xE 'opencode|claude|codex|copilot|cursor-agent'` printed `codex` — this machine's `node` (via nvm) shares a `bin/` directory with a `codex` binary. `pathWithoutAidd()` in `cli/tests/e2e/helpers.ts` builds the sandbox `PATH` from `dirname(process.execPath)` among others, then runs `.filter(withoutDrivableToolBinary)`, which drops any directory holding an AI-tool binary — dropping node's own directory along with it because `codex` sits next to it. Machine-specific: `git diff c3a3355f..HEAD --stat -- cli/` is empty (none of this branch's commits touch `cli/`), and the failing test file was last changed in `95bdbbc3` (2026-09-09), weeks before this task — a pre-existing local gap, not a regression.
+
+No workaround that bypasses or weakens the gate was used: no `--no-verify`, no excluding the job, no editing the test. Instead, the collision itself was fixed for this shell: the `node` binary was copied — not symlinked, since `process.execPath` resolves a symlink back to the original, `codex`-sharing directory — into an isolated directory holding no AI-tool binary, which was then prepended to `PATH` for the push. With that `PATH`, `pnpm exec lefthook run pre-push` passed in full (`cli-knip` ✔️, `cli-test` all passing, no failing file), and `git push -u origin feat/aidd-qa-plugin` completed without `--no-verify`. `cd048e6b` and every commit before it on this branch reached `origin` at that push; confirmed with `git ls-remote origin refs/heads/feat/aidd-qa-plugin` printing that SHA.
+
+This record does not have first-hand detail on how `ba2a59a5`, `e8d3538e`, and `cff70e66` individually reached `origin` — they were not pushed in this session. What is directly known: the session that made this repair started with `cff70e66` already at `origin/feat/aidd-qa-plugin` (its own environment snapshot reported the branch as pushed at that SHA before any repair commit existed), and this repair's own push used the same mechanism as the one detailed above — an isolated directory holding only a copied `node` binary, prepended to `PATH` for `git push`, never `--no-verify` — because the local `node`/`codex` collision this shell sits on has not changed. That push's `pnpm exec lefthook run pre-push` ran `cli-knip` and `cli-test` again (this repair changed no file under `cli/`); its own exit code and `git ls-remote` output are the evidence, not a claim repeated here. This file does not track a single frozen "pushed tip" SHA: the branch's tip is whatever `HEAD` is when the pull request is opened, i.e. the last row of the "Commits" table above at that time. `git ls-remote origin refs/heads/feat/aidd-qa-plugin` is the way to read it, not this paragraph.
+
+## Deviations from the plan
+
+- Updated `scripts/__tests__/architecture-rules.test.js`'s pinned `skillsWithActions` count (48 → 49) — not named in any phase file, required because the suite hardcodes a measured count that a new skill-with-actions legitimately changes.
+- Updated `docs/MAINTAINERS.md`'s package count line (`10 packages (root + 8 plugins + cli)` → `11 packages (root + 9 plugins + cli)`) — not named in phase-3, but it is the same fact `deployment.md` states and would otherwise go stale.
+- `plugins/aidd-qa/README.md` and `docs/CATALOG.md`'s new `aidd-qa` section do not use the `[N.x]` "Bracket ID" numbering the curated plugins use: `aidd-telemetry`, the other off-curated-path plugin, never adopted that convention either (confirmed by grep — no `[8.x]` rows exist in its README), so `aidd-qa` follows the same off-curated precedent rather than inventing a `[9.x]` series nobody else has used since telemetry landed.
+- Root `README.md`'s "Plugins" intro changed from "install all of them" to "install the six stable ones", and the Claude Code install line's off-curated parenthetical grew a third name (`aidd-qa`). The plan asked only for a new tile and corrected counts; this wording change was made because "install all of them" was already inaccurate before this change (it excluded `aidd-ui` and `aidd-telemetry`, both already off the curated path) and adding a third off-curated plugin made the inaccuracy harder to ignore. Flagging it as a judgment call beyond the plan's literal scope rather than reverting it silently.
+- `/aidd-dev:02-implement` was not invoked as a skill; the phases were implemented directly and validated against each phase's own "Test acceptance criteria" table by hand. `/aidd-dev:03-assert` was invoked and its two applicable facets (`01-assert`, `02-assert-architecture`) run as reported above; `03-assert-frontend` was skipped with a stated reason.
+- `phase-1.md` through `phase-4.md` are committed with their original `status: pending` frontmatter unchanged. Only `plan.md`'s `status` was set to `implemented`, per this dispatch's explicit instruction; no instruction named a phase-file status convention, and none was invented.
+- `/aidd-vcs:01-commit` was invoked through the Skill tool for commit 1 only, which surfaced its `01-collect` / `02-message` / `03-commit` process. Commits 2-5 followed that same process by hand (stage the concern's files, message from the imposed text, `git commit`, verify with `git show --stat`) without re-invoking the skill each time. The review #908 repair (commits 9-14) invoked `/aidd-vcs:01-commit` through the Skill tool once for commit 9, then followed the same by-hand process for commits 10-14. The second round (commits 15-16) invoked `/aidd-vcs:01-commit` through the Skill tool for commit 15, then followed the same by-hand process for commit 16.
diff --git a/cli/.claude/skills/framework/SKILL.md b/cli/.claude/skills/framework/SKILL.md
index 45c2ac67d..cad1cfce3 100644
--- a/cli/.claude/skills/framework/SKILL.md
+++ b/cli/.claude/skills/framework/SKILL.md
@@ -24,7 +24,7 @@ disk is exactly the job that needs all three.
| Concept | Location |
|---|---|
-| The manifest aggregate and its members | `domain/manifest.ts`, `domain/manifest/` (tool-entry, tracked-files, merge-files, mcp-exclusions, native-registrations) |
+| The manifest aggregate and its members | `domain/manifest.ts`, `domain/manifest/` (tool-entry, tracked-files, merge-files, native-registrations) |
| A plugin's declared state | `domain/plugins/` (installed-plugin, source-resolver, requested-version-policy) |
| The diagnosis shape | `domain/doctor.ts` |
| Setup orchestration state | `domain/setup-flow.ts` |
diff --git a/cli/README.md b/cli/README.md
index 846943774..0013c0390 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -3,7 +3,7 @@
`@ai-driven-dev/cli` installs AI tool runtime configs, IDE integrations, and plugins from an AIDD marketplace into a project.
Every file it writes is hash-tracked in a manifest, so drift is detected and owned files can be restored.
-Supported AI tools: Claude Code, Cursor, GitHub Copilot, Codex, OpenCode. Supported IDE: VS Code.
+Supported AI tools: Claude Code, Cursor, GitHub Copilot, Codex, OpenCode, Kilo Code. Supported IDE: VS Code.
Requires Node.js >= 22.12, and `git` to fetch marketplace plugins.
## Install
@@ -136,8 +136,9 @@ Two output layouts, chosen by `--as`:
| `copilot` | yes | yes | `.github/` |
| `codex` | yes | yes | `.codex/` |
| `opencode` | no | yes | `.opencode/` |
+| `kilo` | no | yes | `.kilo/` |
-OpenCode declares no marketplace contract, so it is flat only. Every other target accepts both layouts.
+OpenCode and Kilo Code declare no marketplace contract, so they are flat only. Every other target accepts both layouts.
## Environment variables
@@ -184,6 +185,7 @@ Per tool, the settings file the CLI writes:
| GitHub Copilot | Plugin recommendations in `.github/copilot/settings.json`, MCP servers in `.vscode/mcp.json`, plus `.vscode/settings.json` when the VS Code tool is installed too |
| Codex | `.codex/config.toml` |
| OpenCode | `opencode.json`, or `opencode.jsonc` when that is the one present |
+| Kilo Code | `.kilo/kilo.jsonc` by default; an existing `kilo.json[c]` is reused |
| VS Code | `.vscode/settings.json`, `.vscode/extensions.json`, `.vscode/keybindings.json` |
## More
diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md
index 90fe9aac2..cffa92725 100644
--- a/cli/aidd_docs/memory/codebase-map.md
+++ b/cli/aidd_docs/memory/codebase-map.md
@@ -21,6 +21,7 @@ src/
│ │ │ ├── global/
│ │ │ ├── install/
│ │ │ │ └── content/
+│ │ │ ├── ownership/ # machine plugin claims and safe project detach
│ │ │ ├── plugin/
│ │ │ ├── restore/
│ │ │ ├── setup/
@@ -51,6 +52,7 @@ src/
│ │ │ ├── codex/
│ │ │ ├── copilot/
│ │ │ ├── cursor/
+│ │ │ ├── kilo/
│ │ │ ├── opencode/
│ │ │ └── vscode/
│ │ └── infrastructure/
diff --git a/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md b/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md
index 0d6f926f4..911afb58f 100644
--- a/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md
+++ b/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md
@@ -8,14 +8,16 @@
2. Tracked files, merge files, plugin files.
3. `.aidd/` itself. A host needs `.aidd/cache/` alive during step 1.
4. Machine-local files no `plugins[].files` tracks: `.claude/settings.local.json`, a project-merged `.cursor/hooks.json` and its `.cursor/hooks//`, through `application/shared/remove-project-hooks.ts`.
-5. A user-scope plugin directory (`~/.cursor/plugins/local/`) only once `realpath` proves it strictly inside the tool's declared user-scope directory (`domain/plugins/user-scope-containment.ts`). A `..` segment or a post-install symlink is left and named.
+5. No user-scope plugin directory: the user manifest owns those files. Project clean detaches its canonical root only after local cleanup succeeds. A `..` segment or a post-install symlink is refused by explicit user-scope deletion.
6. Right after step 1, per tool driven: the cache root its profile declares (`NativeActivation.pluginCacheDir`), `/`, under the same containment.
- A binary off `PATH` is named and left alone.
- The shared `aidd-framework` registration is the one exception: `undoMarketplaceRegistration` refuses on the scope and warns, naming the host registration, the `marketplaces.json` entry and the tool's cache path.
-- This project's refs are still uninstalled, except one a machine-global host (codex, copilot) enables while `references.json` names another project: left enabled and named.
+- A native ref with an exact `@` claim in the user manifest is machine-owned: project clean never uninstalls it, including when this is the last project. It also leaves a project-labelled Codex/Copilot catalogue registered while an exact machine ref still claims that host catalogue; for non-framework catalogues, no user manifest to prove it unshared also leaves the registration for manual host cleanup. The framework source has its separate `references.json` authority and may not have a user manifest. Project clean detaches this project's root after local cleanup. A preexisting host ref without a machine claim is foreign and never adopted.
+- Targeted user-scope `plugin update` rematerializes machine-owned files for Cursor. Copilot's native `plugin update @` updates an AIDD-owned exact ref through its binary, with dependent projects named and no manual host-cache writes. Codex has no targeted update verb; this command refuses for Codex rather than claiming `upgradeMarketplaces()` updated that one plugin. Use `marketplace refresh` or `framework update` for its catalogue/version workflow.
- The warning names how many other projects still reference the source, or that `clean --scope user` purges it.
- A dry-run reports the same list without dropping anything.
+- Machine claims are changed under an inter-process manifest lock covering load, mutation and save. `plugin add` also holds it while fetching/building and writing the project; a slow operation may make another project wait up to 30 seconds, then fail with a named busy-lock error and require a retry. The lock is never stolen solely because it has aged, so contention cannot silently lose B's claim.
## Cache purge
@@ -25,13 +27,13 @@
## `clean --scope user`
-- The one command that purges the shared source.
+- The one command that purges the shared source and AIDD-owned user-scope plugin files/native refs, after every dependent project has detached.
- The user manifest is optional: a project-scope `setup` never writes one yet leaves the whitelist behind.
-- Without it, steps 1–3 are skipped and said so; other projects in `references.json` are named with the order to run `aidd clean` in each first.
-- Steps 1–2 run at scope `"user"` always, never guessed from the host default.
+- Without it, steps 1–3 are skipped and said so. A live project in `references.json` still blocks source/cache purge, even with `--force`.
+- Steps 1–2 run at scope `"user"` only for native refs AIDD enabled and claimed; a foreign preexisting ref is never uninstalled. Active per-plugin dependents in the user manifest block removal, separately from the `references.json` source claim, even with `--force`. An absent binary, failed host unregister, unreadable source claim or unsafe tracked plugin path aborts without deleting the canonical manifest.
- Step 3: `purgeAllNativeCaches`, shared with project-scope `clean`.
- Step 4, always: a hardcoded whitelist under `userConfigDir()`: `cache/built/` in full, `cache/update-check.json`, root `update-check.json`, the `cache/` shell once a fresh `listDirectory` proves it empty, `references.json`; each re-resolved through `realpath` and `isStrictlyWithinUserScope` before deletion.
- `manifest.json` goes through its repository, the `aidd-framework` entry alone out of `marketplaces.json` through the registry; neither takes a path from the manifest.
- `userConfigDir()` itself is never a candidate.
- Confirmation, unless `--force`, names the source, every version under `cache/built/`, every live project in `references.json`, and the no-registration note.
-- A project pointed at the purged source repairs itself on its next `aidd sync`.
+- A project must clean/detach before this purge; automatic repair of a still-dependent project is not a safety mechanism.
diff --git a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md
index f0597c8d1..be260e1e8 100644
--- a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md
+++ b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md
@@ -16,18 +16,18 @@ One registration of `aidd-framework` per machine, shared by every project. Read
## Migration
-- `MarketplaceRegisterFrameworkUseCase` retires a project-scope entry to the shared one on every `setup` or `sync`.
-- Unconditional, not behind `--force`: `MarketplaceRegistryAdapter.list()` answers project-scope entries first, so a leftover would win forever.
-- The migration carries the entry's own recorded source, never the local-path default.
-- It also repoints a host still tracking *another* project's pre-migration cache, without breaking that project, and records both claims.
-- Codex and copilot have no readable marketplace registry; on a refusal at the reserved name and scope they reclaim it, `remove` then `add`. Never for an arbitrary marketplace.
+- `MarketplaceRegisterFrameworkUseCase` normalizes a project-scope `aidd-framework` entry to the shared registry on `setup` or `sync`. This is local metadata, not authority to take over the native host.
+- `MarketplaceRegistryAdapter.list()` answers project-scope entries first, so a leftover would otherwise shadow the shared entry. Normalization carries the entry's own recorded source, never the local-path default.
+- A Claude host still tracking a pre-migration cache is repointed only if the old machine manifest, current host registry source, old catalogue/version, and complete host refs prove the exact AIDD cache with no foreign ref. It then records both projects' claims.
+- Unproven legacy host state is left for manual reconciliation. Codex and Copilot do not reclaim a reserved name by force when the current source cannot be proved.
+- Native hosts are reconciled only after their current marketplace source and complete plugin references prove AIDD ownership. A reserved-name collision without that proof is left for manual reconciliation; a host without a readable source refuses mutation.
- This project's stale `.aidd/cache/built/aidd-framework/` is deleted only once the run reports no error, no missing binary, no failed build. A host needs that tree to resolve what it unregisters.
## `references.json`
- `userConfigDir()/references.json`: `{ "": ["", …] }` (`contexts/framework/domain/ports/user-source-references.ts`).
-- Written by `setup` and `sync` whenever the framework marketplace resolves to scope `"user"`.
-- `clean` drops only this project's claim, once, never the registration.
+- Written by `setup` and `sync` only after the selected native host registration is proven; a refusal does not create a new claim.
+- `clean` drops only this project's claim, once. It proves current source and host references before any project-scoped host removal; a shared registration is left untouched even if its source cannot be read. A machine-global plugin ref without a canonical machine claim is left enabled for manual reconciliation, not treated as exclusively owned by the last project in `references.json`.
- A help, not an authority: a `projectRoot` deleted with `rm -rf` is ignored at read.
- At zero claims, `clean` names `clean --scope user` as the purge.
- `aidd marketplace remove aidd-framework` refuses the same way; it carries no `--scope user` flag.
@@ -42,12 +42,12 @@ One registration of `aidd-framework` per machine, shared by every project. Read
## `sync`'s write path
- Refuses to write to a host already ahead.
-- Brings a host behind forward as an ordinary update.
+- Brings a host behind forward only after its current source and plugin refs prove AIDD ownership; unsupported Copilot versions refuse native activation.
- Same version: no-op.
## `setup --scope user`
-- Registers the shared source and drives native activation machine-wide.
+- Registers the shared local source; native machine-wide activation requires verified current host source/ref proof. Unsupported Copilot versions refuse it.
- Writes nothing under `projectRoot`: no content, no plugin prompt, no gitignore touch.
- Its manifest: `userManifestPath(userConfigDir())`, `userConfigDir()/manifest.json`, same schema and version as the project one.
- `UserManifestRepositoryAdapter` reuses `Manifest.fromJSON`/`toJSON`; its `delete()` removes that one file only.
diff --git a/cli/aidd_docs/memory/internal/smoke-real.md b/cli/aidd_docs/memory/internal/smoke-real.md
index af3b5d8e7..f6c12a2b9 100644
--- a/cli/aidd_docs/memory/internal/smoke-real.md
+++ b/cli/aidd_docs/memory/internal/smoke-real.md
@@ -28,7 +28,7 @@
Then each host's own inventory: `claude plugin details` lists the fixture's skills, hook events and MCP servers (never its agents: Claude counts none, measured 2026-09-09), `codex plugin list` marks it installed and enabled, `copilot plugin list` enabled. Cursor and opencode expose no inventory command.
3. `doctor`.
4. A host-side `claude plugin uninstall --scope local`, then the `sync --force` that repairs it.
-5. Opencode's bridge.
+5. Opencode's bridge, then a real `opencode run` under the repository's own `aidd-telemetry`: its run journal must hold a `session_start` from opencode. The bridge check alone passed while v5.10.0 journalled nothing (#812).
6. Two `marketplace add` guards: a different catalog under one name, an alias diverging from the catalog's.
7. `setup --scope user`: project clean per `git status --porcelain`, `userConfigDir()/manifest.json` appears, `doctor --scope user` healthy.
8. Two projects sharing one machine-scope marketplace.
diff --git a/cli/aidd_docs/memory/testing.md b/cli/aidd_docs/memory/testing.md
index f9b9a48d6..879e659f7 100644
--- a/cli/aidd_docs/memory/testing.md
+++ b/cli/aidd_docs/memory/testing.md
@@ -34,7 +34,9 @@ How this package is tested: the layers, the tools, the conventions.
- `pnpm test` runs every project; `test:unit`, `test:integration`, `test:e2e`, `test:arch` select one.
- `pnpm smoke` drives the real binary over the command matrix, hermetically. `pnpm smoke:full` adds the remote fetch.
- `pnpm smoke:real` reaches real host registries: [`smoke-real.md`](internal/smoke-real.md).
+- `pnpm smoke:collision` runs the real `codex` and `copilot` binaries in a throwaway HOME against a person's own install under the keys aidd uses, and checks `setup` and `clean` leave it as seeded. `smoke:real`'s unique names cannot meet that case.
- `pnpm test:mutation:`; `mutation-scopes.json` declares each scope's globs (a leading `!` excludes) and the floor its score must hold. `tools` is split one scope per tool profile (`tools-claude`, `tools-codex`, …): a profile is a static declaration whose every mutant reruns each test that loads it, and the profiles together outlasted every other scope on a two-core runner. A weekly scheduled run replays every mutant with `--force`, so drift through a dependency an incremental run never replays is bounded to a week. `scripts/run-mutation.mjs` fails under the floor and keeps one incremental file per scope under `reports/mutation//`; `--force` reruns every mutant. Before a run the runner prunes the incremental file to kills alone: stryker reuses a result unless the mutant's file or a test that covered it changed, so a test written after the fact never reaches a mutant recorded as survived, uncovered or static, and a scope measured 74 read 67 in CI until it did. Raise a floor to the measured score after a run; never lower one without the reason in that file.
+- `node scripts/run-mutation.mjs --changed []` mutates only the lines changed since `` (default `origin/next`) and names each survivor with its file and line. It holds no floor and writes no scope's incremental file, so it checks a branch before a push without moving any gate.
- A unit or integration test reads the repository through `tests/helpers/repository-root.ts`, never by climbing `../` or `process.cwd()`: a mutation run copies `cli/` into a sandbox, where a relative climb lands nowhere (`tests-reach-the-repository-through-one-helper.arch.test.ts`).
- Read counts live: a suite failing before producing a test contributes zero.
diff --git a/cli/assets/configs/kilo/kilo.json b/cli/assets/configs/kilo/kilo.json
new file mode 100644
index 000000000..571a15bec
--- /dev/null
+++ b/cli/assets/configs/kilo/kilo.json
@@ -0,0 +1,3 @@
+{
+ "$schema": "https://app.kilo.ai/config.json"
+}
diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json
index 22611e29f..ccef764bf 100644
--- a/cli/mutation-scopes.json
+++ b/cli/mutation-scopes.json
@@ -1,9 +1,9 @@
{
- "$comment": "The one declaration of what mutation testing covers and the floor each scope must hold. scripts/run-mutation.mjs runs a scope by name and fails below its break; tests/architecture/mutation-covers-source.arch.test.ts checks no source file falls outside both maps, that every scope declares a floor, and that package.json has a script per scope. Globs, never file lists: a list goes stale the day a file is added, and the score does not drop, because the mutants that would have died were never generated. A floor is raised to the measured score after a run, never lowered without the reason here. A scope's mutate is one glob or a list where a leading ! excludes; tools is split one scope per tool profile: a profile is a static declaration whose every mutant reruns each test that loads it, and the five together outlasted every other scope on a two-core runner; vscode's single file stays with the rest.",
+ "$comment": "The one declaration of what mutation testing covers and the floor each scope must hold. scripts/run-mutation.mjs runs a scope by name and fails below its break; tests/architecture/mutation-covers-source.arch.test.ts checks no source file falls outside both maps, that every scope declares a floor, and that package.json has a script per scope. Globs, never file lists: a list goes stale the day a file is added, and the score does not drop, because the mutants that would have died were never generated. A floor is raised to the measured score after a run, never lowered without the reason here. A scope's mutate is one glob or a list where a leading ! excludes; tools is split one scope per tool profile: a profile is a static declaration whose every mutant reruns each test that loads it, and the five together outlasted every other scope on a two-core runner; vscode's single file stays with the rest. canary mutates a fixture, not the product: its one mutant stops a module from loading, which Stryker's vitest runner scores as survived on its own; at 100 the scope fails the day that happens again, so a Stryker or vitest upgrade cannot quietly bypass tests/helpers/unloadable-file-as-failed-test.ts.",
"scopes": {
"kernel": {
"mutate": "src/kernel/**/*.ts",
- "break": 71
+ "break": 95
},
"tools": {
"mutate": [
@@ -12,13 +12,14 @@
"!src/contexts/tools/domain/profiles/codex/**/*.ts",
"!src/contexts/tools/domain/profiles/copilot/**/*.ts",
"!src/contexts/tools/domain/profiles/cursor/**/*.ts",
+ "!src/contexts/tools/domain/profiles/kilo/**/*.ts",
"!src/contexts/tools/domain/profiles/opencode/**/*.ts"
],
"break": 94
},
"telemetry": {
"mutate": "src/contexts/telemetry/**/*.ts",
- "break": 75
+ "break": 93
},
"translate": {
"mutate": "src/contexts/translate/**/*.ts",
@@ -38,27 +39,38 @@
},
"runtime": {
"mutate": "src/runtime/**/*.ts",
- "break": 67
+ "break": 90
},
"tools-claude": {
"mutate": "src/contexts/tools/domain/profiles/claude/**/*.ts",
- "break": 92
+ "break": 94
},
"tools-codex": {
"mutate": "src/contexts/tools/domain/profiles/codex/**/*.ts",
- "break": 87
+ "break": 94
},
"tools-copilot": {
"mutate": "src/contexts/tools/domain/profiles/copilot/**/*.ts",
- "break": 80
+ "break": 95
},
"tools-cursor": {
"mutate": "src/contexts/tools/domain/profiles/cursor/**/*.ts",
- "break": 94
+ "break": 97
},
"tools-opencode": {
"mutate": "src/contexts/tools/domain/profiles/opencode/**/*.ts",
- "break": 81
+ "break": 94
+ },
+ "tools-kilo": {
+ "mutate": "src/contexts/tools/domain/profiles/kilo/**/*.ts",
+ "break": 64
+ },
+ "canary": {
+ "mutate": [
+ "tests/fixtures/stryker-canary/**/*.ts",
+ "!tests/fixtures/stryker-canary/**/*.test.ts"
+ ],
+ "break": 100
}
},
"excluded": {
diff --git a/cli/package.json b/cli/package.json
index c308ca7c4..0c9a4c796 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -36,7 +36,7 @@
"node": ">=22.12"
},
"packageManager": "pnpm@12.3.4",
- "bundleBudgetKB": 654,
+ "bundleBudgetKB": 734,
"scripts": {
"build": "tsup && node scripts/check-bundle-size.mjs",
"build:check-size": "node scripts/check-bundle-size.mjs",
@@ -45,13 +45,15 @@
"test:arch": "vitest run --project=architecture",
"test:unit": "vitest run --project=unit",
"test:integration": "vitest run --project=integration",
- "test:e2e": "vitest run --project=e2e",
+ "test:e2e": "vitest run --project=e2e",
+ "test:e2e:kilo": "KILO_RUNTIME_SMOKE=1 vitest run --project=e2e tests/e2e/kilo-runtime.e2e.test.ts",
"test:coverage": "vitest run --coverage",
"test:kanban": "pnpm --dir ../kanban test",
"test:watch": "vitest",
"smoke": "pnpm build && bash scripts/smoke-tools.sh",
"smoke:full": "pnpm build && SMOKE_REMOTE=1 bash scripts/smoke-tools.sh",
"smoke:real": "pnpm build && bash scripts/smoke-real.sh",
+ "smoke:collision": "pnpm build && bash scripts/smoke-collision.sh",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"format": "biome format --write .",
@@ -65,6 +67,7 @@
"test:mutation:tools-codex": "node scripts/run-mutation.mjs tools-codex",
"test:mutation:tools-copilot": "node scripts/run-mutation.mjs tools-copilot",
"test:mutation:tools-cursor": "node scripts/run-mutation.mjs tools-cursor",
+ "test:mutation:tools-kilo": "node scripts/run-mutation.mjs tools-kilo",
"test:mutation:tools-opencode": "node scripts/run-mutation.mjs tools-opencode",
"test:mutation:telemetry": "node scripts/run-mutation.mjs telemetry",
"test:mutation:translate": "node scripts/run-mutation.mjs translate",
@@ -72,7 +75,7 @@
"test:mutation:framework": "node scripts/run-mutation.mjs framework",
"test:mutation:presentation": "node scripts/run-mutation.mjs presentation",
"test:mutation:runtime": "node scripts/run-mutation.mjs runtime",
- "prepare": "lefthook install",
+ "test:mutation:canary": "node scripts/run-mutation.mjs canary",
"knip": "knip"
},
"dependencies": {
@@ -94,7 +97,6 @@
"fast-check": "^4.7.0",
"jscpd": "^5.0.0",
"knip": "^6.0.0",
- "lefthook": "^2.1.10",
"tsup": "^8.0.0",
"typescript": "^7.0.2",
"vitest": "^3.2.6"
diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml
index 7c5fa503c..4fb58a18c 100644
--- a/cli/pnpm-lock.yaml
+++ b/cli/pnpm-lock.yaml
@@ -106,7 +106,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
- fast-uri: '>=3.1.2'
+ fast-uri: '>=3.1.6 <4'
picomatch: '>=4.0.4'
postcss: '>=8.5.10'
qs: '>=6.15.2'
@@ -117,7 +117,7 @@ importers:
dependencies:
'@inquirer/prompts':
specifier: ^8.5.2
- version: 8.7.0(@types/node@26.4.0)
+ version: 8.7.2(@types/node@26.4.0)
ajv:
specifier: ^8.20.0
version: 8.20.0
@@ -129,7 +129,7 @@ importers:
version: 15.0.0
simple-git:
specifier: ^3.36.0
- version: 3.36.0
+ version: 3.36.0(supports-color@7.2.0)
smol-toml:
specifier: ^1.6.1
version: 1.8.0
@@ -145,16 +145,16 @@ importers:
version: 21.2.2
'@stryker-mutator/core':
specifier: ^9.6.1
- version: 9.6.1(@types/node@26.4.0)
+ version: 9.6.1(@types/node@26.4.0)(supports-color@7.2.0)
'@stryker-mutator/vitest-runner':
specifier: ^9.6.1
- version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.4.0))(vitest@3.2.6(@types/node@26.4.0))
+ version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.4.0)(supports-color@7.2.0))(vitest@3.2.6(@types/node@26.4.0)(supports-color@7.2.0))
'@types/node':
specifier: ^26.1.1
version: 26.4.0
'@vitest/coverage-v8':
specifier: ^3.2.6
- version: 3.2.6(vitest@3.2.6(@types/node@26.4.0))
+ version: 3.2.6(supports-color@7.2.0)(vitest@3.2.6(@types/node@26.4.0)(supports-color@7.2.0))
fast-check:
specifier: ^4.7.0
version: 4.9.0
@@ -164,18 +164,15 @@ importers:
knip:
specifier: ^6.0.0
version: 6.33.0
- lefthook:
- specifier: ^2.1.10
- version: 2.1.12
tsup:
specifier: ^8.0.0
- version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@7.0.2)(yaml@2.9.0)
+ version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(supports-color@7.2.0)(typescript@7.0.2)(yaml@2.9.0)
typescript:
specifier: ^7.0.2
version: 7.0.2
vitest:
specifier: ^3.2.6
- version: 3.2.6(@types/node@26.4.0)
+ version: 3.2.6(@types/node@26.4.0)(supports-color@7.2.0)
packages:
@@ -789,12 +786,12 @@ packages:
cpu: [x64]
os: [win32]
- '@inquirer/ansi@2.0.7':
- resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==}
+ '@inquirer/ansi@2.0.8':
+ resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
- '@inquirer/checkbox@5.2.3':
- resolution: {integrity: sha512-XEYX2WA8SBkLPczL6/yXPHLPCvDoptmh9v56Cy05BSV1Smk1vWy19bTC4qJBuIffw7+6l4CcaYYzGqG60RfW1g==}
+ '@inquirer/checkbox@5.2.5':
+ resolution: {integrity: sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -802,8 +799,8 @@ packages:
'@types/node':
optional: true
- '@inquirer/confirm@6.3.0':
- resolution: {integrity: sha512-pZHXJImFtERmSNMBHcjwuz8Ck5vEFEYNUZnwbb8aJpjHv/TwGuFErNxF2Hp8+V+pNJs2EYPMlyWscvFEqO9jOQ==}
+ '@inquirer/confirm@6.3.2':
+ resolution: {integrity: sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -811,8 +808,8 @@ packages:
'@types/node':
optional: true
- '@inquirer/core@12.0.1':
- resolution: {integrity: sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==}
+ '@inquirer/core@12.0.3':
+ resolution: {integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -820,8 +817,8 @@ packages:
'@types/node':
optional: true
- '@inquirer/editor@5.3.1':
- resolution: {integrity: sha512-y43COoyVUjPWIobn2Qep/uI1drPS78aaZZZ9kVi94Tyu/GuW2N8d8Q4rifJXGAXCEAXCPTTMjD8gC1HyvM5ukA==}
+ '@inquirer/editor@5.3.3':
+ resolution: {integrity: sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -829,8 +826,8 @@ packages:
'@types/node':
optional: true
- '@inquirer/expand@5.1.3':
- resolution: {integrity: sha512-3NQJiXNJ/aj9wiAsr7pECdp5Qe9J0X9YUJCKsaFXS+ddOxfL6J4AIl3w3T4Gq3kK0WsQY5GMoDokK5X94m6lHw==}
+ '@inquirer/expand@5.1.5':
+ resolution: {integrity: sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -838,8 +835,8 @@ packages:
'@types/node':
optional: true
- '@inquirer/external-editor@3.0.4':
- resolution: {integrity: sha512-tZbbaK2ovq6vlrRBNQvjrypmrED/p5x2ncIHQ79cD55tei3dD96v5glMMA+6tiq7K104i/25DVYKWVPJuV6ptA==}
+ '@inquirer/external-editor@3.0.5':
+ resolution: {integrity: sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -847,12 +844,12 @@ packages:
'@types/node':
optional: true
- '@inquirer/figures@2.0.8':
- resolution: {integrity: sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==}
+ '@inquirer/figures@2.0.9':
+ resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
- '@inquirer/input@5.1.4':
- resolution: {integrity: sha512-3xQkQrOvgOzpSN2ciTVdRDlg1FWMCA8l+0KfB6SNlILoTCGzJTzO/gc0Rwjcb3usuGyKdaGtI6OiyMdeMeLWkg==}
+ '@inquirer/input@5.1.6':
+ resolution: {integrity: sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -860,8 +857,8 @@ packages:
'@types/node':
optional: true
- '@inquirer/number@4.2.1':
- resolution: {integrity: sha512-5KaqwZNLRpUuWcoCrYghPP9TMaXL5v2Sk4xqePM7RCVegcJStoXdWibio60YIC1bec+z1fCyb67N6XPJIkZtGA==}
+ '@inquirer/number@4.2.3':
+ resolution: {integrity: sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -869,8 +866,8 @@ packages:
'@types/node':
optional: true
- '@inquirer/password@5.2.0':
- resolution: {integrity: sha512-CvVcW09emkBESEOW+4R8CjLNkP3fB3XrjeL8CDvfpjgrJN+V9oerXmJAXXM3l+4xqYPD5Yaujzy/Ph0PLOdDuA==}
+ '@inquirer/password@5.2.2':
+ resolution: {integrity: sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -878,8 +875,8 @@ packages:
'@types/node':
optional: true
- '@inquirer/prompts@8.7.0':
- resolution: {integrity: sha512-yQwBMYvpJ6jqrXtKiOwRD5XezjJoyt3VQvIyjsr5Arqb519nfIohOQymWVJ8/vEgg8xtZerrCsqlSaqt/LPC9A==}
+ '@inquirer/prompts@8.7.2':
+ resolution: {integrity: sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -887,8 +884,8 @@ packages:
'@types/node':
optional: true
- '@inquirer/rawlist@5.3.3':
- resolution: {integrity: sha512-Mu7WrtmDLaXBDEyrRLS70SZgX9ZSm4Up1w0ZxiH8C1OOp9oaVCn2k8q3QGgmlnhsKYUhuaU3zFWhAP6wxkVIMA==}
+ '@inquirer/rawlist@5.3.5':
+ resolution: {integrity: sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -896,8 +893,8 @@ packages:
'@types/node':
optional: true
- '@inquirer/search@4.3.1':
- resolution: {integrity: sha512-0VWOvsHWI0rPj6CG70MoP4oXNCB6adcyN8bVFZXnh11eLDdPIK2f2XCmva78acPPDfJisfab7qNakwBh7hdBXw==}
+ '@inquirer/search@4.3.3':
+ resolution: {integrity: sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -905,8 +902,8 @@ packages:
'@types/node':
optional: true
- '@inquirer/select@5.2.3':
- resolution: {integrity: sha512-KuRTodDa6xBXX2noIpjuitpX/QT7Sfav7dIZ/OfUY54Hxg95nrGoshSzxx6Ey7qqLbImKdiGkSDt7KjPXjQgmA==}
+ '@inquirer/select@5.2.5':
+ resolution: {integrity: sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -914,8 +911,8 @@ packages:
'@types/node':
optional: true
- '@inquirer/type@4.1.0':
- resolution: {integrity: sha512-FMiJpuHUG3Dk0ex+UIXkre7i+i4OcwHWk9YdcVtZHFwb/r2rnrU2ipTCNAB7A+QOP0ryzIcqOfy76fRyyvOEAw==}
+ '@inquirer/type@4.1.1':
+ resolution: {integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
@@ -1819,8 +1816,8 @@ packages:
fast-string-width@3.0.2:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
- fast-uri@4.0.0:
- resolution: {integrity: sha512-l90y339r2DkZs/ldcWQXcwTjkbp/NbuJDGYoQ3awBgaT3GXOFkm3OkVpz6Z86TywYcya0eVP2r1kTV90f3krGQ==}
+ fast-uri@3.1.7:
+ resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==}
fast-wrap-ansi@0.2.2:
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
@@ -2063,60 +2060,6 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
- lefthook-darwin-arm64@2.1.12:
- resolution: {integrity: sha512-GSUjqaCuxAYlPOovbjXEWE3HAIa/xOQkTTmZ937zieQldjPLTaC7+s5FHoxKywn+D9riBlofkDqG7Z4f5zzmPA==}
- cpu: [arm64]
- os: [darwin]
-
- lefthook-darwin-x64@2.1.12:
- resolution: {integrity: sha512-qAUHSSw/7Afi5wxkoLkxORxSicCeTBXyVLnRgD6YZra6udviozpK3Aok9Z6FIWeKc5FBijRMSNDxGPTREhsjcQ==}
- cpu: [x64]
- os: [darwin]
-
- lefthook-freebsd-arm64@2.1.12:
- resolution: {integrity: sha512-FHZRfKliFNbyz54zsoGdVe9muq67cNjBTZ6jrPPUjI7xUuyCwSpzA+1OpiwJT00L9pP5ZGONBqnGZKnrpC+7Uw==}
- cpu: [arm64]
- os: [freebsd]
-
- lefthook-freebsd-x64@2.1.12:
- resolution: {integrity: sha512-HYQZPGy2DDEx7dbuotusJpujY5q9igOLgx36qeeQcNQuvvdGHPj2ZGSIsGKhoJ0dw5Qa4knKJoPAHEZQWdV/og==}
- cpu: [x64]
- os: [freebsd]
-
- lefthook-linux-arm64@2.1.12:
- resolution: {integrity: sha512-ipjOri2PB/sk4noPrHQuM5fcpNBjmlKo4qMtXyLnxeOxGYHWZzf0Xi0OtrXHQ//tOprcv40ADvhUuSdcGqigLw==}
- cpu: [arm64]
- os: [linux]
-
- lefthook-linux-x64@2.1.12:
- resolution: {integrity: sha512-DmU6xfvqVoFdW+STyc70YI4Ry8vkHGKHx+IJ1Js/Gacq5dv+fiwNzwle3bi28EkL6P8xY67B/CfQfRj4SRU4tg==}
- cpu: [x64]
- os: [linux]
-
- lefthook-openbsd-arm64@2.1.12:
- resolution: {integrity: sha512-vb0J7bElLvoaCWQmna3HntsvoNqMsGAhfjjC99ss1OdCteufZKM3RxCVoMBEbD50Itv2c1Ua2AFVToltVFTy1Q==}
- cpu: [arm64]
- os: [openbsd]
-
- lefthook-openbsd-x64@2.1.12:
- resolution: {integrity: sha512-QOmhDWqPPK4+99scanfEmbJjUR+JYC93qhr/0bp5Dj3LaR3kVFNWc6zOQUsOTPy/LC6PG759Lej/mr/BtjUL2Q==}
- cpu: [x64]
- os: [openbsd]
-
- lefthook-windows-arm64@2.1.12:
- resolution: {integrity: sha512-eErEyr9AHkRFj6TxpCQeKGd0s6IrtL/VEIZ/ssg8dNfG+ycR4jAW/IAlrJSw6/ZQXb3WtWLaQ6QA5c+TLh7vmg==}
- cpu: [arm64]
- os: [win32]
-
- lefthook-windows-x64@2.1.12:
- resolution: {integrity: sha512-0h+WmDVdDriTjloD/UbjPq/ZtqaaJlPAdGcVwMCEdAxfbF0FTgDbKX3igui9g2U/qG2y6QpVhtz1jeiRe7ctaw==}
- cpu: [x64]
- os: [win32]
-
- lefthook@2.1.12:
- resolution: {integrity: sha512-2uOexfsrrRhCiTUWdGyDySxVHHhOFJ8MQejs27dM3hugrQB48/z1ZVlaNoxpZ1SnzQ1GaDIbO5rAvicwyiknYQ==}
- hasBin: true
-
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -2705,20 +2648,20 @@ snapshots:
'@babel/compat-data@7.29.3': {}
- '@babel/core@7.29.0':
+ '@babel/core@7.29.0(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-compilation-targets': 7.28.6
- '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helpers': 7.29.2
'@babel/parser': 7.29.0
'@babel/template': 7.28.6
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.0(supports-color@7.2.0)
'@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -2745,41 +2688,41 @@ snapshots:
lru-cache: 5.1.1
semver: 6.3.1
- '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)':
+ '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
- '@babel/core': 7.29.0
+ '@babel/core': 7.29.0(supports-color@7.2.0)
'@babel/helper-annotate-as-pure': 7.27.3
- '@babel/helper-member-expression-to-functions': 7.28.5
+ '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0)
'@babel/helper-optimise-call-expression': 7.27.1
- '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0)
- '@babel/helper-skip-transparent-expression-wrappers': 7.27.1
- '@babel/traverse': 7.29.0
+ '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0)
+ '@babel/traverse': 7.29.0(supports-color@7.2.0)
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/helper-globals@7.28.0': {}
- '@babel/helper-member-expression-to-functions@7.28.5':
+ '@babel/helper-member-expression-to-functions@7.28.5(supports-color@7.2.0)':
dependencies:
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.0(supports-color@7.2.0)
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-imports@7.28.6':
+ '@babel/helper-module-imports@7.28.6(supports-color@7.2.0)':
dependencies:
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.0(supports-color@7.2.0)
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
+ '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
- '@babel/core': 7.29.0
- '@babel/helper-module-imports': 7.28.6
+ '@babel/core': 7.29.0(supports-color@7.2.0)
+ '@babel/helper-module-imports': 7.28.6(supports-color@7.2.0)
'@babel/helper-validator-identifier': 7.28.5
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.0(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -2789,18 +2732,18 @@ snapshots:
'@babel/helper-plugin-utils@7.28.6': {}
- '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)':
+ '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
- '@babel/core': 7.29.0
- '@babel/helper-member-expression-to-functions': 7.28.5
+ '@babel/core': 7.29.0(supports-color@7.2.0)
+ '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0)
'@babel/helper-optimise-call-expression': 7.27.1
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.0(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
- '@babel/helper-skip-transparent-expression-wrappers@7.27.1':
+ '@babel/helper-skip-transparent-expression-wrappers@7.27.1(supports-color@7.2.0)':
dependencies:
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.0(supports-color@7.2.0)
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -2822,73 +2765,73 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
- '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)':
+ '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
- '@babel/core': 7.29.0
- '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0)
+ '@babel/core': 7.29.0(supports-color@7.2.0)
+ '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0)
+ '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))
transitivePeerDependencies:
- supports-color
- '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)':
+ '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))':
dependencies:
- '@babel/core': 7.29.0
+ '@babel/core': 7.29.0(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)':
+ '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))':
dependencies:
- '@babel/core': 7.29.0
+ '@babel/core': 7.29.0(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)':
+ '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))':
dependencies:
- '@babel/core': 7.29.0
+ '@babel/core': 7.29.0(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)':
+ '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
- '@babel/core': 7.29.0
+ '@babel/core': 7.29.0(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.28.6
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.0(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
- '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)':
+ '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
- '@babel/core': 7.29.0
+ '@babel/core': 7.29.0(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0)
+ '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
- '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)':
+ '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
- '@babel/core': 7.29.0
- '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
+ '@babel/core': 7.29.0(supports-color@7.2.0)
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.28.6
transitivePeerDependencies:
- supports-color
- '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)':
+ '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
- '@babel/core': 7.29.0
+ '@babel/core': 7.29.0(supports-color@7.2.0)
'@babel/helper-annotate-as-pure': 7.27.3
- '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0)
+ '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.28.6
- '@babel/helper-skip-transparent-expression-wrappers': 7.27.1
- '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
+ '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0)
+ '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))
transitivePeerDependencies:
- supports-color
- '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)':
+ '@babel/preset-typescript@7.28.5(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
- '@babel/core': 7.29.0
+ '@babel/core': 7.29.0(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-validator-option': 7.27.1
- '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
- '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0)
- '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
+ '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))
+ '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -2898,7 +2841,7 @@ snapshots:
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
- '@babel/traverse@7.29.0':
+ '@babel/traverse@7.29.0(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
@@ -2906,7 +2849,7 @@ snapshots:
'@babel/parser': 7.29.0
'@babel/template': 7.28.6
'@babel/types': 7.29.0
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -3234,29 +3177,29 @@ snapshots:
'@esbuild/win32-x64@0.27.3':
optional: true
- '@inquirer/ansi@2.0.7': {}
+ '@inquirer/ansi@2.0.8': {}
- '@inquirer/checkbox@5.2.3(@types/node@26.4.0)':
+ '@inquirer/checkbox@5.2.5(@types/node@26.4.0)':
dependencies:
- '@inquirer/ansi': 2.0.7
- '@inquirer/core': 12.0.1(@types/node@26.4.0)
- '@inquirer/figures': 2.0.8
- '@inquirer/type': 4.1.0(@types/node@26.4.0)
+ '@inquirer/ansi': 2.0.8
+ '@inquirer/core': 12.0.3(@types/node@26.4.0)
+ '@inquirer/figures': 2.0.9
+ '@inquirer/type': 4.1.1(@types/node@26.4.0)
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/confirm@6.3.0(@types/node@26.4.0)':
+ '@inquirer/confirm@6.3.2(@types/node@26.4.0)':
dependencies:
- '@inquirer/core': 12.0.1(@types/node@26.4.0)
- '@inquirer/type': 4.1.0(@types/node@26.4.0)
+ '@inquirer/core': 12.0.3(@types/node@26.4.0)
+ '@inquirer/type': 4.1.1(@types/node@26.4.0)
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/core@12.0.1(@types/node@26.4.0)':
+ '@inquirer/core@12.0.3(@types/node@26.4.0)':
dependencies:
- '@inquirer/ansi': 2.0.7
- '@inquirer/figures': 2.0.8
- '@inquirer/type': 4.1.0(@types/node@26.4.0)
+ '@inquirer/ansi': 2.0.8
+ '@inquirer/figures': 2.0.9
+ '@inquirer/type': 4.1.1(@types/node@26.4.0)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.2
mute-stream: 3.0.0
@@ -3264,92 +3207,92 @@ snapshots:
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/editor@5.3.1(@types/node@26.4.0)':
+ '@inquirer/editor@5.3.3(@types/node@26.4.0)':
dependencies:
- '@inquirer/core': 12.0.1(@types/node@26.4.0)
- '@inquirer/external-editor': 3.0.4(@types/node@26.4.0)
- '@inquirer/type': 4.1.0(@types/node@26.4.0)
+ '@inquirer/core': 12.0.3(@types/node@26.4.0)
+ '@inquirer/external-editor': 3.0.5(@types/node@26.4.0)
+ '@inquirer/type': 4.1.1(@types/node@26.4.0)
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/expand@5.1.3(@types/node@26.4.0)':
+ '@inquirer/expand@5.1.5(@types/node@26.4.0)':
dependencies:
- '@inquirer/core': 12.0.1(@types/node@26.4.0)
- '@inquirer/type': 4.1.0(@types/node@26.4.0)
+ '@inquirer/core': 12.0.3(@types/node@26.4.0)
+ '@inquirer/type': 4.1.1(@types/node@26.4.0)
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/external-editor@3.0.4(@types/node@26.4.0)':
+ '@inquirer/external-editor@3.0.5(@types/node@26.4.0)':
dependencies:
chardet: 2.2.0
iconv-lite: 0.7.3
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/figures@2.0.8': {}
+ '@inquirer/figures@2.0.9': {}
- '@inquirer/input@5.1.4(@types/node@26.4.0)':
+ '@inquirer/input@5.1.6(@types/node@26.4.0)':
dependencies:
- '@inquirer/core': 12.0.1(@types/node@26.4.0)
- '@inquirer/type': 4.1.0(@types/node@26.4.0)
+ '@inquirer/core': 12.0.3(@types/node@26.4.0)
+ '@inquirer/type': 4.1.1(@types/node@26.4.0)
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/number@4.2.1(@types/node@26.4.0)':
+ '@inquirer/number@4.2.3(@types/node@26.4.0)':
dependencies:
- '@inquirer/core': 12.0.1(@types/node@26.4.0)
- '@inquirer/type': 4.1.0(@types/node@26.4.0)
+ '@inquirer/core': 12.0.3(@types/node@26.4.0)
+ '@inquirer/type': 4.1.1(@types/node@26.4.0)
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/password@5.2.0(@types/node@26.4.0)':
+ '@inquirer/password@5.2.2(@types/node@26.4.0)':
dependencies:
- '@inquirer/ansi': 2.0.7
- '@inquirer/core': 12.0.1(@types/node@26.4.0)
- '@inquirer/type': 4.1.0(@types/node@26.4.0)
+ '@inquirer/ansi': 2.0.8
+ '@inquirer/core': 12.0.3(@types/node@26.4.0)
+ '@inquirer/type': 4.1.1(@types/node@26.4.0)
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/prompts@8.7.0(@types/node@26.4.0)':
- dependencies:
- '@inquirer/checkbox': 5.2.3(@types/node@26.4.0)
- '@inquirer/confirm': 6.3.0(@types/node@26.4.0)
- '@inquirer/editor': 5.3.1(@types/node@26.4.0)
- '@inquirer/expand': 5.1.3(@types/node@26.4.0)
- '@inquirer/input': 5.1.4(@types/node@26.4.0)
- '@inquirer/number': 4.2.1(@types/node@26.4.0)
- '@inquirer/password': 5.2.0(@types/node@26.4.0)
- '@inquirer/rawlist': 5.3.3(@types/node@26.4.0)
- '@inquirer/search': 4.3.1(@types/node@26.4.0)
- '@inquirer/select': 5.2.3(@types/node@26.4.0)
+ '@inquirer/prompts@8.7.2(@types/node@26.4.0)':
+ dependencies:
+ '@inquirer/checkbox': 5.2.5(@types/node@26.4.0)
+ '@inquirer/confirm': 6.3.2(@types/node@26.4.0)
+ '@inquirer/editor': 5.3.3(@types/node@26.4.0)
+ '@inquirer/expand': 5.1.5(@types/node@26.4.0)
+ '@inquirer/input': 5.1.6(@types/node@26.4.0)
+ '@inquirer/number': 4.2.3(@types/node@26.4.0)
+ '@inquirer/password': 5.2.2(@types/node@26.4.0)
+ '@inquirer/rawlist': 5.3.5(@types/node@26.4.0)
+ '@inquirer/search': 4.3.3(@types/node@26.4.0)
+ '@inquirer/select': 5.2.5(@types/node@26.4.0)
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/rawlist@5.3.3(@types/node@26.4.0)':
+ '@inquirer/rawlist@5.3.5(@types/node@26.4.0)':
dependencies:
- '@inquirer/core': 12.0.1(@types/node@26.4.0)
- '@inquirer/type': 4.1.0(@types/node@26.4.0)
+ '@inquirer/core': 12.0.3(@types/node@26.4.0)
+ '@inquirer/type': 4.1.1(@types/node@26.4.0)
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/search@4.3.1(@types/node@26.4.0)':
+ '@inquirer/search@4.3.3(@types/node@26.4.0)':
dependencies:
- '@inquirer/core': 12.0.1(@types/node@26.4.0)
- '@inquirer/figures': 2.0.8
- '@inquirer/type': 4.1.0(@types/node@26.4.0)
+ '@inquirer/core': 12.0.3(@types/node@26.4.0)
+ '@inquirer/figures': 2.0.9
+ '@inquirer/type': 4.1.1(@types/node@26.4.0)
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/select@5.2.3(@types/node@26.4.0)':
+ '@inquirer/select@5.2.5(@types/node@26.4.0)':
dependencies:
- '@inquirer/ansi': 2.0.7
- '@inquirer/core': 12.0.1(@types/node@26.4.0)
- '@inquirer/figures': 2.0.8
- '@inquirer/type': 4.1.0(@types/node@26.4.0)
+ '@inquirer/ansi': 2.0.8
+ '@inquirer/core': 12.0.3(@types/node@26.4.0)
+ '@inquirer/figures': 2.0.9
+ '@inquirer/type': 4.1.1(@types/node@26.4.0)
optionalDependencies:
'@types/node': 26.4.0
- '@inquirer/type@4.1.0(@types/node@26.4.0)':
+ '@inquirer/type@4.1.1(@types/node@26.4.0)':
optionalDependencies:
'@types/node': 26.4.0
@@ -3383,9 +3326,9 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
- '@kwsites/file-exists@1.1.1':
+ '@kwsites/file-exists@1.1.1(supports-color@7.2.0)':
dependencies:
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -3619,11 +3562,11 @@ snapshots:
tslib: 2.8.1
typed-inject: 5.0.0
- '@stryker-mutator/core@9.6.1(@types/node@26.4.0)':
+ '@stryker-mutator/core@9.6.1(@types/node@26.4.0)(supports-color@7.2.0)':
dependencies:
- '@inquirer/prompts': 8.7.0(@types/node@26.4.0)
+ '@inquirer/prompts': 8.7.2(@types/node@26.4.0)
'@stryker-mutator/api': 9.6.1
- '@stryker-mutator/instrumenter': 9.6.1
+ '@stryker-mutator/instrumenter': 9.6.1(supports-color@7.2.0)
'@stryker-mutator/util': 9.6.1
ajv: 8.18.0
chalk: 5.6.2
@@ -3651,14 +3594,14 @@ snapshots:
- '@types/node'
- supports-color
- '@stryker-mutator/instrumenter@9.6.1':
+ '@stryker-mutator/instrumenter@9.6.1(supports-color@7.2.0)':
dependencies:
- '@babel/core': 7.29.0
+ '@babel/core': 7.29.0(supports-color@7.2.0)
'@babel/generator': 7.29.1
'@babel/parser': 7.29.0
- '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
- '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0)
- '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
+ '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)
+ '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)
'@stryker-mutator/api': 9.6.1
'@stryker-mutator/util': 9.6.1
angular-html-parser: 10.4.0
@@ -3670,14 +3613,14 @@ snapshots:
'@stryker-mutator/util@9.6.1': {}
- '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.4.0))(vitest@3.2.6(@types/node@26.4.0))':
+ '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.4.0)(supports-color@7.2.0))(vitest@3.2.6(@types/node@26.4.0)(supports-color@7.2.0))':
dependencies:
'@stryker-mutator/api': 9.6.1
- '@stryker-mutator/core': 9.6.1(@types/node@26.4.0)
+ '@stryker-mutator/core': 9.6.1(@types/node@26.4.0)(supports-color@7.2.0)
'@stryker-mutator/util': 9.6.1
semver: 7.7.4
tslib: 2.8.1
- vitest: 3.2.6(@types/node@26.4.0)
+ vitest: 3.2.6(@types/node@26.4.0)(supports-color@7.2.0)
'@tybys/wasm-util@0.10.3':
dependencies:
@@ -3757,22 +3700,22 @@ snapshots:
'@typescript/typescript-win32-x64@7.0.2':
optional: true
- '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@26.4.0))':
+ '@vitest/coverage-v8@3.2.6(supports-color@7.2.0)(vitest@3.2.6(@types/node@26.4.0)(supports-color@7.2.0))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
ast-v8-to-istanbul: 0.3.12
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
- istanbul-lib-source-maps: 5.0.6
+ istanbul-lib-source-maps: 5.0.6(supports-color@7.2.0)
istanbul-reports: 3.2.0
magic-string: 0.30.21
magicast: 0.3.5
std-env: 3.10.0
test-exclude: 7.0.2
tinyrainbow: 2.0.0
- vitest: 3.2.6(@types/node@26.4.0)
+ vitest: 3.2.6(@types/node@26.4.0)(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -3827,14 +3770,14 @@ snapshots:
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
- fast-uri: 4.0.0
+ fast-uri: 3.1.7
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
- fast-uri: 4.0.0
+ fast-uri: 3.1.7
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -3991,9 +3934,11 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
- debug@4.4.3:
+ debug@4.4.3(supports-color@7.2.0):
dependencies:
ms: 2.1.3
+ optionalDependencies:
+ supports-color: 7.2.0
deep-eql@5.0.2: {}
@@ -4128,7 +4073,7 @@ snapshots:
dependencies:
fast-string-truncated-width: 3.0.3
- fast-uri@4.0.0: {}
+ fast-uri@3.1.7: {}
fast-wrap-ansi@0.2.2:
dependencies:
@@ -4260,10 +4205,10 @@ snapshots:
make-dir: 4.0.0
supports-color: 7.2.0
- istanbul-lib-source-maps@5.0.6:
+ istanbul-lib-source-maps@5.0.6(supports-color@7.2.0):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
@@ -4350,49 +4295,6 @@ snapshots:
yaml: 2.9.0
zod: 4.5.4
- lefthook-darwin-arm64@2.1.12:
- optional: true
-
- lefthook-darwin-x64@2.1.12:
- optional: true
-
- lefthook-freebsd-arm64@2.1.12:
- optional: true
-
- lefthook-freebsd-x64@2.1.12:
- optional: true
-
- lefthook-linux-arm64@2.1.12:
- optional: true
-
- lefthook-linux-x64@2.1.12:
- optional: true
-
- lefthook-openbsd-arm64@2.1.12:
- optional: true
-
- lefthook-openbsd-x64@2.1.12:
- optional: true
-
- lefthook-windows-arm64@2.1.12:
- optional: true
-
- lefthook-windows-x64@2.1.12:
- optional: true
-
- lefthook@2.1.12:
- optionalDependencies:
- lefthook-darwin-arm64: 2.1.12
- lefthook-darwin-x64: 2.1.12
- lefthook-freebsd-arm64: 2.1.12
- lefthook-freebsd-x64: 2.1.12
- lefthook-linux-arm64: 2.1.12
- lefthook-linux-x64: 2.1.12
- lefthook-openbsd-arm64: 2.1.12
- lefthook-openbsd-x64: 2.1.12
- lefthook-windows-arm64: 2.1.12
- lefthook-windows-x64: 2.1.12
-
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@@ -4686,13 +4588,13 @@ snapshots:
signal-exit@4.1.0: {}
- simple-git@3.36.0:
+ simple-git@3.36.0(supports-color@7.2.0):
dependencies:
- '@kwsites/file-exists': 1.1.1
+ '@kwsites/file-exists': 1.1.1(supports-color@7.2.0)
'@kwsites/promise-deferred': 1.1.1
'@simple-git/args-pathspec': 1.0.3
'@simple-git/argv-parser': 1.1.1
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -4801,13 +4703,13 @@ snapshots:
tslib@2.8.1: {}
- tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@7.0.2)(yaml@2.9.0):
+ tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(supports-color@7.2.0)(typescript@7.0.2)(yaml@2.9.0):
dependencies:
bundle-require: 5.1.0(esbuild@0.27.3)
cac: 6.7.14
chokidar: 4.0.3
consola: 3.4.2
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
esbuild: 0.27.3
fix-dts-default-cjs-exports: 1.0.1
joycon: 3.1.1
@@ -4880,10 +4782,10 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
- vite-node@3.2.4(@types/node@26.4.0):
+ vite-node@3.2.4(@types/node@26.4.0)(supports-color@7.2.0):
dependencies:
cac: 6.7.14
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.4.21(@types/node@26.4.0)
@@ -4907,7 +4809,7 @@ snapshots:
'@types/node': 26.4.0
fsevents: 2.3.3
- vitest@3.2.6(@types/node@26.4.0):
+ vitest@3.2.6(@types/node@26.4.0)(supports-color@7.2.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.6
@@ -4918,7 +4820,7 @@ snapshots:
'@vitest/spy': 3.2.6
'@vitest/utils': 3.2.6
chai: 5.3.3
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
@@ -4930,7 +4832,7 @@ snapshots:
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 5.4.21(@types/node@26.4.0)
- vite-node: 3.2.4(@types/node@26.4.0)
+ vite-node: 3.2.4(@types/node@26.4.0)(supports-color@7.2.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.4.0
diff --git a/cli/pnpm-workspace.yaml b/cli/pnpm-workspace.yaml
index b03326216..d59cf5c71 100644
--- a/cli/pnpm-workspace.yaml
+++ b/cli/pnpm-workspace.yaml
@@ -11,7 +11,7 @@ packages: []
# security floors were silently ignored while the lockfile still recorded them, which is
# the ERR_PNPM_LOCKFILE_CONFIG_MISMATCH every `cli` install answered with.
overrides:
- fast-uri: '>=3.1.2'
+ fast-uri: '>=3.1.6 <4'
picomatch: '>=4.0.4'
postcss: '>=8.5.10'
qs: '>=6.15.2'
diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs
index 69a7864aa..c99bfadca 100644
--- a/cli/scripts/check-bundle-size.mjs
+++ b/cli/scripts/check-bundle-size.mjs
@@ -6,7 +6,7 @@ const root = resolve(fileURLToPath(import.meta.url), "../..");
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
// The budget makes growth visible rather than walling it off: a raise is deliberate, is
-// what a reviewer sees, and leaves ~2 % headroom over what was measured, never more.
+// what a reviewer sees, and leaves at most ~2 % headroom over what was measured.
// The registry of every raise, budget then measurement then what landed:
// 560 KB: 500.8 KB, measurement across five tools.
// 590 KB: 567.7 KB, one person resolved across tools and machines.
@@ -22,6 +22,14 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
// 625 KB: 612.56 KB, `--scope user` on `setup`, `doctor` and `sync`.
// 641 KB: 628.26 KB, `clean --scope user` and sync's migration of a pre-shared-source project.
// 654 KB: 641.0 KB, the shared-plugin, narrowing, hook and Windows-lookup passes.
+// 682 KB: 675.5 KB, canonical per-plugin machine claims, scoped user operations and
+// inter-process ownership guards for Cursor, Codex and Copilot (+31.3 KB over next's 644.2).
+// 710 KB: 703.8 KB, current native host-source proof and exact hook/MCP/user-file provenance
+// guards for #829 (+22.0 KB over the first 829 candidate's 681.8); 0.9% measured headroom.
+// 722 KB: 714.2 KB, exact native-claim partition, multihost preflight, and symlink boundary
+// guards for #829 (+10.4 KB over the preceding 703.8 KB); 1.1% measured headroom.
+// 734 KB: 725.8 KB, Kilo Code's profile, generated bridge, and runtime smoke support (+9.6 KB
+// over next's 716.2 KB); 1.1% measured headroom.
const budgetKB = pkg.bundleBudgetKB ?? 500;
const budgetBytes = budgetKB * 1024;
diff --git a/cli/scripts/run-mutation.d.mts b/cli/scripts/run-mutation.d.mts
index 36b9d5278..3aea3128d 100644
--- a/cli/scripts/run-mutation.d.mts
+++ b/cli/scripts/run-mutation.d.mts
@@ -7,7 +7,14 @@ export interface MutationReport {
readonly files?: Readonly<
Record<
string,
- { readonly mutants: readonly { readonly status: string; readonly static?: boolean }[] }
+ {
+ readonly mutants: readonly {
+ readonly status: string;
+ readonly static?: boolean;
+ readonly mutatorName?: string;
+ readonly location?: { readonly start: { readonly line: number } };
+ }[];
+ }
>
>;
}
@@ -21,3 +28,6 @@ export function strykerArgs(
): string[];
export function scoreOf(report: MutationReport): number;
export function breakVerdict(score: number, declared: MutationScope): string | null;
+export function changedRanges(diff: string): string[];
+export function changedArgs(ranges: readonly string[]): string[];
+export function survivorsOf(report: MutationReport): string[];
diff --git a/cli/scripts/run-mutation.mjs b/cli/scripts/run-mutation.mjs
index 3e178e051..daa554615 100644
--- a/cli/scripts/run-mutation.mjs
+++ b/cli/scripts/run-mutation.mjs
@@ -56,14 +56,53 @@ export function pruneIncremental(report) {
for (const [name, file] of Object.entries(report.files ?? {})) {
files[name] = {
...file,
- mutants: file.mutants.filter(
- (mutant) => !mutant.static && (mutant.status === "Killed" || mutant.status === "Timeout")
- ),
+ mutants: file.mutants.filter((mutant) => !mutant.static && mutant.status === "Killed"),
};
}
return { ...report, files };
}
+const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
+
+/** The lines a `git diff -U0` adds or changes under `src/`, as stryker `file:start-end` ranges.
+ * A pure deletion adds no line to mutate, and a file outside `src/` belongs to no scope. */
+export function changedRanges(diff) {
+ const ranges = [];
+ let file = null;
+ for (const line of diff.split("\n")) {
+ if (line.startsWith("+++ ")) {
+ const target = line.slice(4);
+ file = target.startsWith("b/") ? target.slice(2) : null;
+ continue;
+ }
+ const hunk = HUNK.exec(line);
+ if (hunk === null || file === null || !file.startsWith("src/") || !file.endsWith(".ts"))
+ continue;
+ const start = Number(hunk[1]);
+ const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
+ if (count > 0) ranges.push(`${file}:${start}-${start + count - 1}`);
+ }
+ return ranges;
+}
+
+/** No scope, no incremental file and no floor: a check on a branch must never move a gate. */
+export function changedArgs(ranges) {
+ return ["run", "--mutate", ranges.join(",")];
+}
+
+export function survivorsOf(report) {
+ const survivors = [];
+ for (const [name, file] of Object.entries(report.files ?? {})) {
+ for (const mutant of file.mutants) {
+ if (mutant.status !== "Survived" && mutant.status !== "NoCoverage") continue;
+ survivors.push(
+ `${name}:${mutant.location.start.line} ${mutant.mutatorName} (${mutant.status})`
+ );
+ }
+ }
+ return survivors;
+}
+
/** Below the declared floor is a failure the run itself raises; stryker's own `thresholds`
* would need a config file per scope to say the same thing. */
export function breakVerdict(score, declared) {
@@ -86,14 +125,56 @@ function pruneIncrementalFile(path) {
}
function usage(problem, scopes) {
- console.error(`${problem}\n\nUsage: node scripts/run-mutation.mjs [--force]`);
+ console.error(
+ `${problem}\n\nUsage: node scripts/run-mutation.mjs [--force]\n node scripts/run-mutation.mjs --changed []`
+ );
console.error(`Scopes: ${Object.keys(scopes).join(", ")}`);
process.exit(1);
}
+function git(args) {
+ const result = spawnSync("git", args, { cwd: CLI_ROOT, encoding: "utf8" });
+ if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
+ return result.stdout.trim();
+}
+
+function fileReports(dir) {
+ for (const name of WRITTEN_REPORTS) {
+ const written = join(REPORT_ROOT, name);
+ if (existsSync(written)) renameSync(written, join(dir, name));
+ }
+}
+
+function mainChanged(base = "origin/next") {
+ const mergeBase = git(["merge-base", base, "HEAD"]);
+ const ranges = changedRanges(git(["diff", "-U0", "--relative", mergeBase, "--", "src"]));
+ if (ranges.length === 0) {
+ console.log(`No line under src/ changed since ${base}: nothing to mutate.`);
+ return;
+ }
+ const dir = join(REPORT_ROOT, "changed");
+ mkdirSync(dir, { recursive: true });
+ const result = spawnSync(join(CLI_ROOT, "node_modules", ".bin", "stryker"), changedArgs(ranges), {
+ cwd: CLI_ROOT,
+ stdio: "inherit",
+ });
+ rmSync(join(CLI_ROOT, ".stryker-tmp"), { recursive: true, force: true });
+ fileReports(dir);
+ if (result.status !== 0) process.exit(result.status ?? 1);
+ const report = JSON.parse(readFileSync(join(dir, "mutation.json"), "utf8"));
+ const mutants = Object.values(report.files ?? {}).flatMap((file) => file.mutants).length;
+ const measured =
+ mutants === 0 ? "no mutant on those lines" : `score ${scoreOf(report).toFixed(1)}`;
+ console.log(
+ `\nReport: reports/mutation/changed/ (${measured}, ${ranges.length} changed range(s) since ${base})`
+ );
+ for (const survivor of survivorsOf(report)) console.log(` survived: ${survivor}`);
+}
+
function main() {
const scopes = loadScopes();
const [scope, ...flags] = process.argv.slice(2);
+ if (scope === "--changed") return mainChanged(flags[0]);
if (scope === undefined) usage("No scope given.", scopes);
if (!Object.hasOwn(scopes, scope)) usage(`Unknown scope "${scope}".`, scopes);
const force = flags.includes("--force");
@@ -111,10 +192,7 @@ function main() {
// A sandbox survives an interrupted run and they grow to hundreds of megabytes.
rmSync(join(CLI_ROOT, ".stryker-tmp"), { recursive: true, force: true });
- for (const name of WRITTEN_REPORTS) {
- const written = join(REPORT_ROOT, name);
- if (existsSync(written)) renameSync(written, join(scopeDir, name));
- }
+ fileReports(scopeDir);
if (result.status !== 0) process.exit(result.status ?? 1);
diff --git a/cli/scripts/smoke-collision.sh b/cli/scripts/smoke-collision.sh
new file mode 100755
index 000000000..4ff431807
--- /dev/null
+++ b/cli/scripts/smoke-collision.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+# Real host binaries against a person's own install under the keys aidd uses:
+# `@aidd-framework` for codex and copilot, the plugin name alone for cursor.
+# `smoke-real.sh` gives every run a unique name so it never meets a real install, which is
+# exactly why it cannot see this collision. Everything here runs in a throwaway HOME (and
+# CODEX_HOME); the person's install is seeded there, never read from the real one.
+# Never in CI, never in lefthook: `pnpm smoke:collision`.
+set -uo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+CLI="${AIDD_CLI:-$ROOT/dist/cli.js}"
+FIXTURE="$ROOT/tests/fixtures/framework-real"
+PLUGIN="aidd-vcs"
+REF="$PLUGIN@aidd-framework"
+
+[[ -f "$CLI" ]] || { echo "FATAL: $CLI missing — run 'pnpm build' first"; exit 1; }
+
+PASS=0; FAIL=0; SKIP=0
+ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
+bad() { FAIL=$((FAIL+1)); echo " ✗ $1"; }
+skip() { SKIP=$((SKIP+1)); echo " ~ $1"; }
+
+TMPROOT=$(mktemp -d -t aidd-smoke-collision-XXXXXXXX)
+trap 'rm -rf "$TMPROOT"' EXIT
+export HOME="$TMPROOT/home" USERPROFILE="$TMPROOT/home" AIDD_USER_CONFIG_DIR="$TMPROOT/config"
+export CODEX_HOME="$HOME/.codex"
+unset XDG_CONFIG_HOME AIDD_TELEMETRY_DIR
+
+CODEX_CONFIG="$CODEX_HOME/config.toml"
+CODEX_CACHE_WITNESS="$CODEX_HOME/plugins/cache/aidd-framework/foreign-payload/bytes"
+COPILOT_SETTINGS="$HOME/.copilot/settings.json"
+CURSOR_MANIFEST="$HOME/.cursor/plugins/local/$PLUGIN/.cursor-plugin/plugin.json"
+CODEX_SECTION="[plugins.\"$REF\"]"
+CURSOR_MINE='{"name":"aidd-vcs","version":"0.0.1","mine":true}'
+CODEX_CACHE_MINE='foreign host cache bytes must remain unchanged'
+CODEX_FOREIGN_FIXTURE="$ROOT/tests/fixtures/plugins/codex-format/marketplace-foreign-aidd"
+COPILOT_FOREIGN_FIXTURE="$ROOT/tests/fixtures/plugins/copilot-format/marketplace-multi-sample"
+
+TOOLS=(cursor)
+for t in codex copilot; do
+ if command -v "$t" >/dev/null 2>&1; then TOOLS+=("$t"); else skip "$t not installed on PATH"; fi
+done
+mkdir -p "$CODEX_HOME" "$(dirname "$CODEX_CACHE_WITNESS")" "$(dirname "$COPILOT_SETTINGS")" "$(dirname "$CURSOR_MANIFEST")"
+printf '%s\nenabled = true\n' "$CODEX_SECTION" > "$CODEX_CONFIG"
+printf '%s\n' "$CODEX_CACHE_MINE" > "$CODEX_CACHE_WITNESS"
+if command -v codex >/dev/null 2>&1; then
+ codex plugin marketplace add "$CODEX_FOREIGN_FIXTURE" --json >"$TMPROOT/codex-seed.log" 2>&1 \
+ && ok "codex: foreign same-name catalogue seeded" || bad "codex: foreign catalogue seed failed"
+fi
+if command -v copilot >/dev/null 2>&1; then
+ copilot plugin marketplace add "$COPILOT_FOREIGN_FIXTURE" >"$TMPROOT/copilot-seed.log" 2>&1 \
+ && ok "copilot: foreign same-name catalogue seeded" || bad "copilot: foreign catalogue seed failed"
+fi
+node -e 'const fs=require("fs");const p=process.argv[1],r=process.argv[2];let s={};try{s=JSON.parse(fs.readFileSync(p,"utf8"))}catch{}s.enabledPlugins={...(s.enabledPlugins||{}),[r]:true};fs.writeFileSync(p,JSON.stringify(s)+"\n")' "$COPILOT_SETTINGS" "$REF"
+printf '%s\n' "$CURSOR_MINE" > "$CURSOR_MANIFEST"
+
+copilot_key() {
+ node -e "const s=JSON.parse(require(\"fs\").readFileSync(process.argv[1],\"utf8\"));console.log(String(s.enabledPlugins && s.enabledPlugins[process.argv[2]]))" "$COPILOT_SETTINGS" "$REF" 2>/dev/null || echo unreadable
+}
+
+codex_catalog_source() {
+ codex plugin marketplace list --json 2>/dev/null | node -e 'let s="";process.stdin.on("data",b=>s+=b);process.stdin.on("end",()=>{try{const m=JSON.parse(s).marketplaces.find(x=>x.name==="aidd-framework");console.log(m?.marketplaceSource?.source??"unproven")}catch{console.log("unreadable")}})' 2>/dev/null || echo unreadable
+}
+
+copilot_catalog_source() {
+ node -e 'try{const s=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(s.extraKnownMarketplaces?.["aidd-framework"]?.source?.path??"unproven")}catch{console.log("unreadable")}' "$COPILOT_SETTINGS" 2>/dev/null || echo unreadable
+}
+
+still_theirs() {
+ local step="$1" t
+ for t in "${TOOLS[@]}"; do
+ case "$t" in
+ codex) grep -qxF "$CODEX_SECTION" "$CODEX_CONFIG" && ok "codex: the person's $REF is still enabled after $step" \
+ || bad "codex: the person's $REF section is gone after $step"
+ [[ "$(codex_catalog_source)" == "$CODEX_FOREIGN_FIXTURE" ]] \
+ && ok "codex: foreign same-name catalogue source unchanged after $step" \
+ || bad "codex: foreign same-name catalogue source changed after $step"
+ [[ "$(cat "$CODEX_CACHE_WITNESS" 2>/dev/null)" == "$CODEX_CACHE_MINE" ]] \
+ && ok "codex: foreign marketplace cache bytes are unchanged after $step" \
+ || bad "codex: foreign marketplace cache bytes changed or vanished after $step" ;;
+ copilot) [[ "$(copilot_key)" == "true" ]] && ok "copilot: the person's $REF is still enabled after $step" \
+ || bad "copilot: the person's $REF reads '$(copilot_key)' after $step"
+ [[ "$(copilot_catalog_source)" == "$COPILOT_FOREIGN_FIXTURE" ]] \
+ && ok "copilot: foreign same-name catalogue source unchanged after $step" \
+ || bad "copilot: foreign same-name catalogue source changed after $step" ;;
+ cursor) [[ "$(cat "$CURSOR_MANIFEST" 2>/dev/null)" == "$CURSOR_MINE" ]] && ok "cursor: the person's plugin.json is untouched after $step" \
+ || bad "cursor: the person's plugin.json changed or vanished after $step" ;;
+ esac
+ done
+}
+
+PROJECT="$TMPROOT/project"; mkdir -p "$PROJECT"; (cd "$PROJECT" && git init -q)
+ai=$(IFS=,; echo "${TOOLS[*]}")
+echo "CLI: $CLI"; echo "Hosts: $ai"
+
+(cd "$PROJECT" && node "$CLI" setup --source local --path "$FIXTURE" --ai "$ai" --plugins "$PLUGIN" --yes) "$TMPROOT/setup.log" 2>&1 \
+ && ok "setup --ai $ai --plugins $PLUGIN" || bad "setup exited $? (see output below)"
+still_theirs "setup"
+(cd "$PROJECT" && node "$CLI" clean --force) "$TMPROOT/clean.log" 2>&1 \
+ && ok "clean --force" || bad "clean exited $?"
+still_theirs "clean"
+
+if [[ "$FAIL" -gt 0 ]]; then
+ echo "--- setup output"; cat "$TMPROOT/setup.log"; echo "--- clean output"; cat "$TMPROOT/clean.log"
+fi
+echo "PASS: $PASS FAIL: $FAIL SKIP: $SKIP"
+[[ "$FAIL" -eq 0 ]]
diff --git a/cli/scripts/smoke-real.sh b/cli/scripts/smoke-real.sh
index 51ff408b9..22d718f66 100644
--- a/cli/scripts/smoke-real.sh
+++ b/cli/scripts/smoke-real.sh
@@ -615,6 +615,46 @@ else
skip "opencode bridge check (opencode not installed)"
fi
+section "opencode: a real session under aidd-telemetry writes a journal line"
+# The bridge check above installs the smoke fixture, which journals nothing, so it passed while
+# every OpenCode session recorded nothing (#812). This installs the repository's own
+# aidd-telemetry on the layout a user gets, and asks the journal itself.
+if [[ -n "${PRESENT[opencode]:-}" ]]; then
+ PROJ_T=$(mktemp -d "$TMPROOT/proj-telemetry.XXXXXX"); (cd "$PROJ_T" && git init -q)
+ PROJECTS+=("$PROJ_T")
+ MKT_T="$MKT-telemetry"
+ run "setup (opencode, files only)" 0 "" "$PROJ_T" -- \
+ node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai opencode \
+ --no-default-marketplace --plugins none --yes
+ run "marketplace add $MKT_T (this repository)" 0 "" "$PROJ_T" -- \
+ node "$CLI" marketplace add "$MKT_T" "$ROOT/.." --scope project --yes
+ run "plugin install aidd-telemetry -> opencode" 0 "" "$PROJ_T" -- \
+ node "$CLI" plugin install aidd-telemetry --tool opencode --from "$MKT_T" --yes
+ run "telemetry on" 0 "" "$PROJ_T" -- node "$CLI" telemetry on --yes
+
+ tel_out=$(mktemp)
+ ( cd "$PROJ_T" && exec perl -e 'alarm shift; exec @ARGV' 90 opencode run "say ok" ) "$tel_out" 2>&1
+ tel_rc=$?
+ cat "$tel_out" >> "$LOGFILE"
+ # The session opens on its first call, so only a turn that completed can prove an empty
+ # journal: exit 0 with no line is #812 itself, a model that never answered proves nothing.
+ if grep -qsE '"type":"session_start".*"tool":"opencode"' "$PROJ_T"/aidd_docs/runs/*.jsonl; then
+ ok "opencode: the run journal holds a session_start line from opencode"
+ elif [[ "$tel_rc" -eq 0 ]]; then
+ bad "opencode: the turn completed and the run journal holds no session_start line" \
+ "$(ls -la "$PROJ_T/aidd_docs/runs" 2>&1; cat "$tel_out")"
+ elif [[ "$tel_rc" -eq 142 ]]; then
+ skip "opencode journal: the model never answered within 90s, so the journal proves nothing"
+ elif grep -qiE "auth|api key|provider|not logged in|credential" "$tel_out"; then
+ skip "opencode journal: needs provider auth on this machine — exit $tel_rc"
+ else
+ bad "opencode: exit $tel_rc and the run journal holds no session_start line" "$(cat "$tel_out")"
+ fi
+ rm -f "$tel_out"
+else
+ skip "opencode journal check (opencode not installed)"
+fi
+
# --- Phase C1: the guard refuses a genuinely different catalog under the same name ---
# Identity is a catalog's declared name plus its plugin set, never a path and never a version
# (`marketplace-source-conflict.ts`), so this fixture keeps the name `$MKT` and drops its one
diff --git a/cli/scripts/smoke-tools.sh b/cli/scripts/smoke-tools.sh
index da217b49c..57dd7971a 100755
--- a/cli/scripts/smoke-tools.sh
+++ b/cli/scripts/smoke-tools.sh
@@ -17,7 +17,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CLI="$ROOT/dist/cli.js"
FRAMEWORK_FIXTURE="$ROOT/tests/fixtures/framework"
-AI_TOOLS=(claude cursor copilot codex opencode)
+AI_TOOLS=(claude cursor copilot codex opencode kilo)
IDE_TOOLS=(vscode)
# Canonical leaf-command surface. Coverage = exercised / total.
@@ -42,6 +42,27 @@ ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
bad() { FAIL=$((FAIL+1)); FAILURES+=("$1"$'\n'"${2:-}"); echo " ✗ $1"; }
skip() { SKIP=$((SKIP+1)); echo " ~ $1"; }
section() { echo; echo "=== $1 === [$(date +%H:%M:%S)]"; }
+project_tree_hash() {
+ (cd "$1" && node -e '
+ const fs = require("node:fs");
+ const path = require("node:path");
+ const hash = require("node:crypto").createHash("sha256");
+ function walk(dir) {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
+ const absolute = path.join(dir, entry.name);
+ if (entry.isDirectory()) walk(absolute);
+ else if (entry.isFile()) {
+ hash.update(path.relative(process.cwd(), absolute));
+ hash.update("\0");
+ hash.update(fs.readFileSync(absolute));
+ hash.update("\0");
+ }
+ }
+ }
+ walk(process.cwd());
+ process.stdout.write(hash.digest("hex"));
+ ' )
+}
PARENTS=" plugin marketplace auth framework telemetry "
# A parent one level deeper than PARENTS, so a covered key needs three words there, not two.
@@ -180,14 +201,15 @@ section "update --check"
out=$(cd "$ROOT" && node "$CLI" update --check 2>&1); rc=$?
if [[ "$rc" -eq 0 || "$rc" -eq 1 ]]; then mark_covered "update"; ok "update --check (exit $rc)"; else bad "update crashed (exit $rc)" "$out"; fi
-# Comparing the file list before and after is the only assertion that proves `--dry-run`
-# wrote nothing.
+# Compare exact file paths and contents before/after: `--dry-run` must write nothing.
P_DRY=$(new_project)
(cd "$P_DRY" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --plugins none --yes >/dev/null 2>&1)
-before_dry=$(cd "$P_DRY" && find . -type f | sort | md5)
+before_dry=$(project_tree_hash "$P_DRY"); before_dry_rc=$?
run "update --dry-run" "0|1" "" "$P_DRY" -- update --dry-run
-after_dry=$(cd "$P_DRY" && find . -type f | sort | md5)
-if [[ "$before_dry" == "$after_dry" ]]; then
+after_dry=$(project_tree_hash "$P_DRY"); after_dry_rc=$?
+if [[ "$before_dry_rc" -ne 0 || "$after_dry_rc" -ne 0 ]]; then
+ bad "--dry-run project tree hash failed"
+elif [[ "$before_dry" == "$after_dry" ]]; then
ok "--dry-run wrote nothing"
else
bad "--dry-run changed the project tree"
@@ -210,15 +232,17 @@ run "marketplace add --overwrite" 0 "" "$P_MKT" -- marketplace add local "$MKT_S
# Passing `--scope` is not enough: the two values must write to different places.
P_SCOPE=$(new_project)
(cd "$P_SCOPE" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --plugins none --yes >/dev/null 2>&1)
-run "marketplace add --scope project" 0 "" "$P_SCOPE" -- marketplace add scoped "$MKT_SRC" --yes --scope project
+PROJECT_MKT_SRC="$TMPROOT/project-mkt-src"; mkdir -p "$PROJECT_MKT_SRC/.claude-plugin"
+printf '%s' '{"name":"project-mkt","owner":{"name":"smoke"},"version":"1.0.0","plugins":[]}' > "$PROJECT_MKT_SRC/.claude-plugin/marketplace.json"
+run "marketplace add --scope project" 0 "" "$P_SCOPE" -- marketplace add scoped "$PROJECT_MKT_SRC" --yes --scope project
proj_reg="$P_SCOPE/.aidd/marketplaces.json"
if [[ -f "$proj_reg" ]] && grep -q "scoped" "$proj_reg"; then
ok "--scope project writes the project registry"
else
bad "--scope project did not write $proj_reg"
fi
-# A second source with its own manifest name: the tool keys its registry by the name inside
-# the marketplace, so two aidd marketplaces sharing a source would collide rather than scope.
+# A third source with its own manifest name: the tool keys its registry by the name inside
+# the marketplace, so each scope assertion needs a distinct host name.
USER_MKT_SRC="$TMPROOT/user-mkt-src"; mkdir -p "$USER_MKT_SRC/.claude-plugin"
printf '%s' '{"name":"user-mkt","owner":{"name":"smoke"},"version":"1.0.0","plugins":[]}' > "$USER_MKT_SRC/.claude-plugin/marketplace.json"
run "marketplace add --scope user" 0 "" "$P_SCOPE" -- marketplace add userscoped "$USER_MKT_SRC" --yes --scope user
@@ -234,9 +258,9 @@ if command -v claude >/dev/null 2>&1; then
claude_local="$P_SCOPE/.claude/settings.local.json"
claude_home="$HOME/.claude/settings.json"
# Names the marketplace, not the generic `extraKnownMarketplaces` key any declaration would
- # satisfy. Keyed by `local-mkt`, the catalog's own declared name: this file is written by
+ # satisfy. Keyed by `project-mkt`, the catalog's own declared name: this file is written by
# `hostName`, never by `scoped`, aidd's local alias for the same entry.
- if [[ -f "$claude_local" ]] && grep -q '"local-mkt"' "$claude_local"; then
+ if [[ -f "$claude_local" ]] && grep -q '"project-mkt"' "$claude_local"; then
ok "claude declares the project marketplace at local scope"
else
bad "claude has no local-scope declaration in $claude_local"
@@ -278,8 +302,13 @@ if true; then
run "setup --release (local source)" 0 "" "$P_REL" -- \
setup --source local --path "$FRAMEWORK_FIXTURE" --release v1.0.0 --ai claude --plugins none --yes
for t in "${AI_TOOLS[@]}"; do
- [[ -d "$BASE/.${t}" || ( "$t" == copilot && -d "$BASE/.github" ) ]] \
- && ok "$t dir present" || bad "$t dir missing after --ai all"
+ if [[ "$t" == copilot ]]; then
+ [[ ! -d "$BASE/.github" ]] \
+ && ok "copilot declarative settings absent without native source proof" \
+ || bad "copilot declarative settings written without native source proof"
+ else
+ [[ -d "$BASE/.${t}" ]] && ok "$t dir present" || bad "$t dir missing after --ai all"
+ fi
done
[[ -d "$BASE/.vscode" ]] && ok "vscode dir present" || bad "vscode dir missing"
# Cursor is `installScope: "user"`, so its plugin files land under $HOME and never under
@@ -322,7 +351,7 @@ if true; then
run "sync --force" 0 "" "$BASE" -- sync --force
repaired "sync --force" "$tgt"
- section "framework install/update/remove --tool × all 5 AI tools + vscode"
+ section "framework install/update/remove --tool × all 6 AI tools + vscode"
run "framework update (all)" 0 "" "$BASE" -- framework update
run "framework rules" 0 "" "$BASE" -- framework rules
run "framework rules --json" 0 "" "$BASE" -- framework rules --json
@@ -362,10 +391,34 @@ if true; then
(cd "$P_PLUG" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai all --plugins none --yes >/dev/null 2>&1)
for t in "${AI_TOOLS[@]}"; do
run "plugin install aidd-test → $t" 0 "" "$P_PLUG" -- plugin install aidd-test --tool "$t" --yes
- run "plugin remove → $t" 0 "" "$P_PLUG" -- plugin remove aidd-test --tool "$t"
- # `--from` names the marketplace explicitly.
- run "plugin install --from → $t" 0 "" "$P_PLUG" -- \
- plugin install aidd-test --tool "$t" --from aidd-framework --yes
+ # An absent host permits local removal with a warning; an available host must first
+ # prove its native source. Exercise the matching contract, never infer native absence.
+ if [[ "$t" == copilot ]] && command -v copilot >/dev/null 2>&1; then
+ copilot_project_before=$(project_tree_hash "$P_PLUG")
+ copilot_home_before=$(project_tree_hash "$HOME")
+ run "plugin remove → copilot (unproven source refusal)" 1 \
+ "no recorded native catalogue source" "$P_PLUG" -- plugin remove aidd-test --tool copilot
+ copilot_project_after=$(project_tree_hash "$P_PLUG")
+ copilot_home_after=$(project_tree_hash "$HOME")
+ [[ -n "$copilot_project_before" && -n "$copilot_home_before" && \
+ "$copilot_project_before" == "$copilot_project_after" && \
+ "$copilot_home_before" == "$copilot_home_after" ]] \
+ && ok "copilot refusal wrote no project or home bytes" \
+ || bad "copilot refusal changed project or home bytes"
+ run "plugin install --from → copilot (no duplicate after refusal)" 1 \
+ "already installed" "$P_PLUG" -- \
+ plugin install aidd-test --tool copilot --from aidd-framework --yes
+ elif [[ "$t" == copilot ]]; then
+ run "plugin remove → copilot (missing host warns, local removal succeeds)" 0 \
+ "copilot CLI not found on PATH" "$P_PLUG" -- plugin remove aidd-test --tool copilot
+ run "plugin install --from → copilot (local reinstall without host)" 0 "" "$P_PLUG" -- \
+ plugin install aidd-test --tool copilot --from aidd-framework --yes
+ else
+ run "plugin remove → $t" 0 "" "$P_PLUG" -- plugin remove aidd-test --tool "$t"
+ # `--from` names the marketplace explicitly.
+ run "plugin install --from → $t" 0 "" "$P_PLUG" -- \
+ plugin install aidd-test --tool "$t" --from aidd-framework --yes
+ fi
done
run "plugin remove aidd-test (claude)" 0 "" "$P_PLUG" -- plugin remove aidd-test --tool claude
@@ -547,14 +600,14 @@ has_subcommands() {
}
leaves_under() {
- local path=("$@") name
+ local name
# `cut -d'|'`: commander prints an alias as `update|upgrade`, one command with two names.
- for name in $(node "$CLI" "${path[@]}" --help 2>/dev/null | awk '/^Commands:/{f=1;next} f && /^ [a-z]/{print $1}' | cut -d'|' -f1); do
+ for name in $(node "$CLI" "$@" --help 2>/dev/null | awk '/^Commands:/{f=1;next} f && /^ [a-z]/{print $1}' | cut -d'|' -f1); do
[[ "$name" == "help" ]] && continue
- if has_subcommands "${path[@]}" "$name"; then
- leaves_under "${path[@]}" "$name"
+ if has_subcommands "$@" "$name"; then
+ leaves_under "$@" "$name"
else
- echo "${path[*]} $name" | sed 's/^ *//'
+ echo "$* $name" | sed 's/^ *//'
fi
done
}
diff --git a/cli/src/contexts/distribution/application/marketplace-add-use-case.ts b/cli/src/contexts/distribution/application/marketplace-add-use-case.ts
index 456ca7e4e..986bcffe5 100644
--- a/cli/src/contexts/distribution/application/marketplace-add-use-case.ts
+++ b/cli/src/contexts/distribution/application/marketplace-add-use-case.ts
@@ -7,7 +7,15 @@ import {
import type { Prompter } from "../../../kernel/ports/prompter.js";
import type { MarketplaceScope } from "../../../kernel/scope.js";
import type { PluginSource } from "../../../kernel/source.js";
-import type { MarketplaceRemoveUseCase } from "../../framework/application/flows/marketplace-remove-use-case.js";
+import type {
+ MarketplaceRemoveOptions,
+ MarketplaceRemoveResult,
+} from "../../framework/application/flows/marketplace-remove-use-case.js";
+
+export interface MarketplaceRemover {
+ execute(options: MarketplaceRemoveOptions): Promise;
+}
+
import { FRAMEWORK_MARKETPLACE_NAME, Marketplace } from "../domain/marketplace.js";
import type { MarketplaceRegistry } from "../domain/ports/marketplace-registry.js";
import type { MarketplaceTrustStore } from "../domain/ports/marketplace-trust-store.js";
@@ -32,7 +40,7 @@ export class MarketplaceAddUseCase {
private readonly trustStore: MarketplaceTrustStore,
private readonly resolveMarketplace: ResolveMarketplaceUseCase,
private readonly prompter: Prompter,
- private readonly removeUseCase: MarketplaceRemoveUseCase
+ private readonly removeUseCase: MarketplaceRemover
) {}
async execute(options: MarketplaceAddOptions): Promise {
@@ -69,7 +77,7 @@ export class MarketplaceAddUseCase {
const found = existing.find((m) => m.name === name);
if (!found) return;
if (!overwrite) throw new MarketplaceAlreadyRegisteredError(name);
- await this.removeUseCase.execute({ name, projectRoot, autoConfirm: true });
+ await this.removeUseCase.execute({ name, projectRoot, autoConfirm: true, scope: found.scope });
}
private async ensureTrust(options: MarketplaceAddOptions): Promise {
diff --git a/cli/src/contexts/framework/application/clean-use-case.ts b/cli/src/contexts/framework/application/clean-use-case.ts
index 0e59ccdb6..f9c53fd2d 100644
--- a/cli/src/contexts/framework/application/clean-use-case.ts
+++ b/cli/src/contexts/framework/application/clean-use-case.ts
@@ -19,9 +19,11 @@ import { resolveHomeDir } from "../../../kernel/reading/home-dir.js";
import type { MarketplaceScope } from "../../../kernel/scope.js";
import type { AiToolId, ToolId } from "../../../kernel/tool.js";
import { isAiToolId } from "../../../kernel/tool.js";
+import { FRAMEWORK_MARKETPLACE_NAME } from "../../distribution/domain/marketplace.js";
import type { MarketplaceRegistry } from "../../distribution/domain/ports/marketplace-registry.js";
import type { HostMarketplaceRegistryReader } from "../../tools/domain/ports/host-marketplace-registry-reader.js";
import type { HostPluginRegistryReader } from "../../tools/domain/ports/host-plugin-registry-reader.js";
+import type { NativeMarketplaceSourceReader } from "../../tools/domain/ports/native-marketplace-source-reader.js";
import type { NativePluginActivator } from "../../tools/domain/ports/native-plugin-activator.js";
import {
machineLocalFilesOf,
@@ -36,13 +38,23 @@ import type { InstalledPlugin } from "../domain/plugins/installed-plugin.js";
import type { ManifestRepository } from "../domain/ports/manifest-repository.js";
import type { UserSourceReferences } from "../domain/ports/user-source-references.js";
import type { GitignoreUseCase } from "./gitignore-use-case.js";
+import {
+ assertNoForeignNativeRefs,
+ inspectNativeMarketplaceSource,
+} from "./ownership/native-marketplace-source-proof.js";
+import {
+ detachNativePluginRefs,
+ machineNativePluginClaim,
+} from "./ownership/native-plugin-ownership.js";
+import { assertProjectMcpEntriesRemovable } from "./ownership/project-plugin-cleanup.js";
+import { detachUserPlugin } from "./ownership/user-plugin-ownership.js";
import { deletePluginFilesForTool } from "./plugin/plugin-helpers.js";
import { bestEffortNativeCall } from "./shared/best-effort-native-call.js";
import {
purgeAllNativeCaches,
type UndoneToolRegistrations,
} from "./shared/purge-native-marketplace-cache.js";
-import { removeProjectHooks } from "./shared/remove-project-hooks.js";
+import { assertProjectHooksRemovable, removeProjectHooks } from "./shared/remove-project-hooks.js";
import { resolveUninstallScopeOrder } from "./shared/resolve-uninstall-scope.js";
import {
describeGuardedPluginRefMessage,
@@ -54,7 +66,7 @@ import {
} from "./shared/shared-source-reference-support.js";
import { userScopeFilesSafeToDelete } from "./shared/user-scope-plugin-files.js";
-/** What dropping this project's own reference to the shared source found — `undefined` only
+/** What reading this project's shared source reference found — `undefined` only
* when there is nothing to guard on at all: the port is absent, or no shared, machine-scope
* marketplace is registered locally. `otherProjects` is read regardless of whether this
* project's own claim was there to drop, since another project's claim is a fact worth
@@ -62,6 +74,7 @@ import { userScopeFilesSafeToDelete } from "./shared/user-scope-plugin-files.js"
* per tool into that tool's `hostName` — never the alias, which a host never learns. */
interface SharedSourceReferenceOutcome {
readonly alias: string;
+ readonly resolvedRoot: string;
readonly otherProjects: readonly string[];
}
@@ -134,7 +147,9 @@ export class CleanUseCase {
private readonly hostPluginRegistries: ReadonlyMap<
AiToolId,
HostPluginRegistryReader
- > = new Map()
+ > = new Map(),
+ private readonly userManifestRepo?: ManifestRepository,
+ private readonly nativeSources: ReadonlyMap = new Map()
) {}
async execute(options: CleanOptions): Promise {
@@ -147,10 +162,26 @@ export class CleanUseCase {
const preview = await this.buildPreview(manifest, home, options.projectRoot);
const dryRunResult = await this.confirmOrDryRun(options, preview);
if (dryRunResult !== null) return dryRunResult;
- // Decremented exactly once per run, before the per-tool loop: the shared source's reference
- // count is a project-level fact, and claude, codex and copilot can each carry their own ref,
- // so decrementing inside that loop would drop this project's claim once per tool.
- const sharedSourceOutcome = await this.dropSharedSourceReference(options.projectRoot);
+ await this.assertNativeSourcesUnchanged(manifest, options.projectRoot);
+ for (const toolId of manifest.getInstalledToolIds()) {
+ if (!isAiToolId(toolId)) continue;
+ for (const plugin of manifest.getPlugins(toolId)) {
+ if (projectHooksFileOf(toolId) !== undefined) {
+ await assertProjectHooksRemovable(this.fs, plugin, toolId, options.projectRoot);
+ }
+ await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, options.projectRoot);
+ }
+ }
+ // Read the other projects before host cleanup, but keep this project's claim until every
+ // local cleanup step succeeds. A failed file or gitignore cleanup must not free the source.
+ const sharedSourceOutcome = await this.withSharedSourceClaims(
+ options.projectRoot,
+ async (references, alias, resolvedRoot) => ({
+ alias,
+ resolvedRoot,
+ otherProjects: await otherProjectsReferencing(references, resolvedRoot),
+ })
+ );
// Undoing a host's own registration must happen before any of the rest: the tool's CLI
// resolves the marketplace name against the built tree under `.aidd/cache/`, which
// `removeAiddState` deletes next, and a host may refuse to unregister a source that is gone.
@@ -169,9 +200,76 @@ export class CleanUseCase {
await this.removeAiddState(options.projectRoot);
// Exactly what the pipeline added on install, never a subset of it.
await this.gitignoreUseCase.remove(options.projectRoot, aiddGitignoreEntries(manifest));
+ // One project-level decrement, only after the local projection has been cleaned successfully.
+ await this.dropSharedSourceReference(sharedSourceOutcome?.resolvedRoot);
+ const nativeRefs = new Map();
+ for (const toolId of manifest.getInstalledToolIds()) {
+ const refs = manifest.getNativeRegistrations(toolId)?.pluginRefs;
+ if (refs !== undefined && refs.length > 0) nativeRefs.set(toolId, refs);
+ }
+ await detachNativePluginRefs(this.userManifestRepo, this.fs, options.projectRoot, nativeRefs);
+ for (const toolId of manifest.getInstalledToolIds()) {
+ if (!isAiToolId(toolId)) continue;
+ for (const plugin of manifest.getPlugins(toolId)) {
+ if (plugin.scope === "user") {
+ await detachUserPlugin(
+ this.userManifestRepo,
+ this.fs,
+ toolId,
+ plugin.name,
+ options.projectRoot
+ );
+ }
+ }
+ }
return { dryRun: false, manifestFound: true, preview, fileCount: deleted };
}
+ /** A project clean must not decrement shared claims before proving the host names it may undo. */
+ private async assertNativeSourcesUnchanged(
+ manifest: Manifest,
+ projectRoot: string
+ ): Promise {
+ const projectMarketplaces = (await this.marketplaceRegistry?.list(projectRoot)) ?? [];
+ const machineManifest = await this.userManifestRepo?.load();
+ for (const toolId of manifest.getInstalledToolIds()) {
+ if (!isAiToolId(toolId)) continue;
+ const registrations = manifest.getNativeRegistrations(toolId);
+ if (registrations === undefined) continue;
+ const activator = this.activators.get(registrations.binary);
+ if (activator === undefined || !activator.isAvailable()) continue;
+ for (const registration of registrations.marketplaces) {
+ if (
+ projectMarketplaces.find((marketplace) => marketplace.name === registration.alias)
+ ?.scope !== "project"
+ ) {
+ continue;
+ }
+ const proof = await inspectNativeMarketplaceSource(
+ this.nativeSources.get(toolId),
+ projectRoot,
+ registration
+ );
+ if (proof.status !== "owned") {
+ throw new Error(
+ proof.reason ??
+ `${toolId}: catalogue '${registration.hostName}' is absent on the host; clean refused until manual reconciliation.`
+ );
+ }
+ const machineRefs =
+ machineManifest
+ ?.getNativeRegistrations(toolId)
+ ?.pluginClaims?.map((claim) => claim.ref) ?? [];
+ await assertNoForeignNativeRefs(
+ this.hostPluginRegistries.get(toolId),
+ registration.hostName,
+ new Set([...registrations.pluginRefs, ...machineRefs]),
+ projectRoot
+ );
+ }
+ }
+ }
+
// `config.json` is the committed telemetry switch: a file clean did not write, so clean never
// removes it. Everything AIDD did write must go before the emptiness check, or its own presence
// blocks a removal that should happen — the registry `marketplace add` writes included.
@@ -306,36 +404,57 @@ export class CleanUseCase {
);
return false;
}
- return bestEffortNativeCall(
- this.logger,
- () => activator.removeMarketplace(hostName, marketplace.scope),
- `${binary} marketplace remove '${hostName}'`
- );
+ const removeProjectRegistration = async (): Promise => {
+ if (pluginEnablementIsMachineGlobal(toolId)) {
+ const machine = await this.userManifestRepo?.load();
+ if (machine === undefined || machine === null) {
+ if (marketplace.name !== FRAMEWORK_MARKETPLACE_NAME) {
+ this.logger.warn(
+ `${binary}: '${hostName}' is machine-global but no user manifest can prove no other project still uses it — left registered for manual cleanup.`
+ );
+ return false;
+ }
+ }
+ const claims =
+ machine
+ ?.getNativeRegistrations(toolId)
+ ?.pluginClaims?.filter((claim) => claim.ref.endsWith(`@${hostName}`)) ?? [];
+ if (claims.length > 0) {
+ const others = [
+ ...new Set(
+ claims.flatMap((claim) =>
+ claim.dependents.filter((dependent) => dependent !== projectRoot)
+ )
+ ),
+ ];
+ this.logger.warn(
+ `${binary}: '${hostName}' carries AIDD-owned machine plugin refs — left registered for explicit user-scope removal.${others.length > 0 ? ` Still needed by ${others.join(", ")}.` : ""}`
+ );
+ return false;
+ }
+ }
+ return bestEffortNativeCall(
+ this.logger,
+ () => activator.removeMarketplace(hostName, marketplace.scope),
+ `${binary} marketplace remove '${hostName}'`
+ );
+ };
+ return this.userManifestRepo?.withExclusiveAccess === undefined
+ ? removeProjectRegistration()
+ : this.userManifestRepo.withExclusiveAccess(removeProjectRegistration);
}
- /** Decrements this project's own claim exactly once per `clean` run, independent of how many
- * tools' registrations name it: the count in `references.json` is per project, never per tool.
- * Never reads a "current" CLI version to decide which key to touch, so a self-update between
- * the `sync` that wrote the reference and this `clean` cannot strand it. `undefined` only when
- * the port was never wired in, or no shared marketplace is registered locally — never merely
- * because this project's own claim was already missing. */
- private async dropSharedSourceReference(
- projectRoot: string
- ): Promise {
- return this.withSharedSourceClaims(
- projectRoot,
- async (userSourceReferences, alias, resolvedRoot) => {
- // A no-op when this project's own registry never held the shared entry, which must never
- // collapse into "no other projects": another project's claim is worth guarding on
- // regardless, so `otherProjects` is always read in full.
- await userSourceReferences.removeReference(resolvedRoot);
- const otherProjects = await otherProjectsReferencing(userSourceReferences, resolvedRoot);
- return { alias, otherProjects };
- }
+ /** Decrements this project's claim exactly once after local cleanup. The resolved root was
+ * captured before `.aidd/` and its local marketplace registry were deleted. */
+ private async dropSharedSourceReference(resolvedRoot: string | undefined): Promise {
+ const references = this.userSourceReferences;
+ if (references === undefined || resolvedRoot === undefined) return;
+ await toleratingUnreadableSourceReferences(this.logger, undefined, () =>
+ references.removeReference(resolvedRoot)
);
}
- /** Shared preamble behind `dropSharedSourceReference` and `previewSharedSourceOtherProjects`:
+ /** Shared preamble behind the cleanup read and `previewSharedSourceOtherProjects`:
* `undefined` when the port was never wired in or no shared, machine-scope registration exists
* to act on. `action` alone decides whether the run only reads or also writes. */
private async withSharedSourceClaims(
@@ -417,6 +536,27 @@ export class CleanUseCase {
registrations: NativeRegistrations,
sharedSourceOutcome: SharedSourceReferenceOutcome | undefined
): Promise {
+ const claim = await machineNativePluginClaim(this.userManifestRepo, toolId, ref);
+ if (claim !== undefined) {
+ const others = claim.dependents.filter((dependent) => dependent !== projectRoot);
+ this.logger.warn(
+ `${binary}: '${ref}' is an AIDD-owned machine plugin ref — left enabled; project claim detached after local clean.${others.length > 0 ? ` Still needed by ${others.join(", ")}.` : ""}`
+ );
+ return;
+ }
+ if (pluginEnablementIsMachineGlobal(toolId)) {
+ const sharedHostName = registrations.marketplaces.find(
+ (registration) => registration.alias === sharedSourceOutcome?.alias
+ )?.hostName;
+ const otherProjects =
+ sharedHostName !== undefined && ref.endsWith(`@${sharedHostName}`)
+ ? (sharedSourceOutcome?.otherProjects ?? [])
+ : [];
+ this.logger.warn(
+ `${binary}: '${ref}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.${otherProjects.length > 0 ? ` Still needed by ${otherProjects.join(", ")}.` : ""}`
+ );
+ return;
+ }
const guardMessage = this.describeGuardedPluginRef(
binary,
toolId,
@@ -428,7 +568,40 @@ export class CleanUseCase {
this.logger.warn(guardMessage);
return;
}
+ const sourceRegistration = registrations.marketplaces.find((registration) =>
+ ref.endsWith(`@${registration.hostName}`)
+ );
+ if (sourceRegistration === undefined) {
+ this.logger.warn(`${binary}: '${ref}' has no recorded host catalogue — left enabled.`);
+ return;
+ }
+ const proof = await inspectNativeMarketplaceSource(
+ isAiToolId(toolId) ? this.nativeSources.get(toolId) : undefined,
+ projectRoot,
+ sourceRegistration
+ );
+ if (proof.status !== "owned") {
+ this.logger.warn(
+ `${binary}: '${ref}' left enabled because its current host catalogue is unproven. ${proof.reason ?? "Reconcile manually."}`
+ );
+ return;
+ }
const reader = isAiToolId(toolId) ? this.hostPluginRegistries.get(toolId) : undefined;
+ const machineRefs =
+ (await this.userManifestRepo?.load())
+ ?.getNativeRegistrations(toolId)
+ ?.pluginClaims?.map((machineClaim) => machineClaim.ref) ?? [];
+ try {
+ await assertNoForeignNativeRefs(
+ reader,
+ sourceRegistration.hostName,
+ new Set([...registrations.pluginRefs, ...machineRefs]),
+ projectRoot
+ );
+ } catch (error) {
+ this.logger.warn(`${binary}: '${ref}' left enabled; ${String(error)}`);
+ return;
+ }
const manifestScope = this.manifestScopeForRef(manifest, toolId, registrations, ref);
const order = await resolveUninstallScopeOrder(reader, ref, projectRoot, manifestScope);
let lastMessage = "";
@@ -535,7 +708,7 @@ export class CleanUseCase {
if (projectHooksFileOf(toolId) === undefined || !isAiToolId(toolId)) return 0;
let count = 0;
for (const plugin of manifest.getPlugins(toolId)) {
- if (await removeProjectHooks(this.fs, plugin.name, toolId, projectRoot)) count++;
+ if (await removeProjectHooks(this.fs, plugin, toolId, projectRoot)) count++;
}
return count;
}
@@ -624,6 +797,7 @@ export class CleanUseCase {
): Promise {
let count = 0;
for (const plugin of manifest.getPlugins(toolId)) {
+ if (plugin.scope === "user") continue;
const files = await this.filesSafeToDelete(plugin, toolId);
const deleted = await deletePluginFilesForTool(
files,
diff --git a/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts b/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts
index 37f138110..e02784dd0 100644
--- a/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts
+++ b/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts
@@ -1,4 +1,5 @@
import { join } from "node:path";
+import { ActiveMachineDependentsError } from "../../../../kernel/errors.js";
import { USER_SOURCE_REFERENCES_FILENAME, userBuiltCacheRoot } from "../../../../kernel/paths.js";
import type { FileReader } from "../../../../kernel/ports/file-reader.js";
import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
@@ -9,12 +10,18 @@ import { type AiToolId, isAiToolId, type ToolId } from "../../../../kernel/tool.
import { FRAMEWORK_MARKETPLACE_NAME } from "../../../distribution/domain/marketplace.js";
import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js";
import type { HostMarketplaceRegistryReader } from "../../../tools/domain/ports/host-marketplace-registry-reader.js";
+import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js";
+import type { NativeMarketplaceSourceReader } from "../../../tools/domain/ports/native-marketplace-source-reader.js";
import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js";
import { nativeActivationOf } from "../../../tools/domain/registry.js";
import type { NativeRegistrations } from "../../domain/manifest/native-registrations.js";
import type { Manifest } from "../../domain/manifest.js";
import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
import type { UserSourceReferences } from "../../domain/ports/user-source-references.js";
+import {
+ assertNoForeignNativeRefs,
+ inspectNativeMarketplaceSource,
+} from "../ownership/native-marketplace-source-proof.js";
import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js";
import { bestEffortNativeCall } from "../shared/best-effort-native-call.js";
import { resolveCacheCandidate } from "../shared/purge-declared-cache.js";
@@ -22,10 +29,7 @@ import {
purgeAllNativeCaches,
type UndoneToolRegistrations,
} from "../shared/purge-native-marketplace-cache.js";
-import {
- describeFullRemovalInstruction,
- toleratingUnreadableSourceReferences,
-} from "../shared/shared-source-reference-support.js";
+import { describeFullRemovalInstruction } from "../shared/shared-source-reference-support.js";
import { userScopeFilesSafeToDelete } from "../shared/user-scope-plugin-files.js";
export interface CleanUserScopeOptions {
@@ -39,6 +43,7 @@ export interface CleanUserScopeOptions {
export interface CleanUserScopePreview {
toolIds: readonly ToolId[];
+ activePluginDependents: readonly string[];
/** Every version directory found under `userConfigDir()/cache/built/` — read structurally,
* never trusted from any one tool's own manifest entry. */
builtVersions: readonly string[];
@@ -82,18 +87,48 @@ export class CleanUserScopeUseCase {
private readonly homeDir: () => string = resolveHomeDir,
/** Absent reports no referencing project at all rather than guessing one. */
private readonly userSourceReferences?: UserSourceReferences,
- private readonly prompter?: Prompter
+ private readonly prompter?: Prompter,
+ private readonly nativeSources: ReadonlyMap<
+ AiToolId,
+ NativeMarketplaceSourceReader
+ > = new Map(),
+ private readonly hostPluginRegistries: ReadonlyMap<
+ AiToolId,
+ HostPluginRegistryReader
+ > = new Map()
) {}
async execute(options: CleanUserScopeOptions): Promise {
+ if (this.userManifestRepo.withExclusiveAccess !== undefined) {
+ return this.userManifestRepo.withExclusiveAccess(() => this.executeLocked(options));
+ }
+ return this.executeLocked(options);
+ }
+
+ private async executeLocked(options: CleanUserScopeOptions): Promise {
const manifest = await this.userManifestRepo.load();
const manifestFound = manifest !== null;
const preview = await this.buildPreview(manifest);
if (!manifestFound) this.logger.info(this.describeNoUserRegistration(preview));
const dryRunResult = await this.confirmOrDryRun(options, preview, manifestFound);
if (dryRunResult !== null) return dryRunResult;
+ if (preview.activePluginDependents.length > 0) {
+ throw new ActiveMachineDependentsError(
+ "user-scope plugin files",
+ preview.activePluginDependents
+ );
+ }
+ if (preview.referencingProjects.length > 0) {
+ throw new ActiveMachineDependentsError(
+ "the shared framework source",
+ preview.referencingProjects
+ );
+ }
if (manifest !== null) {
+ await this.assertTrackedUserPluginFilesSafe(manifest);
+ this.assertNativeActivatorsAvailable(manifest);
+ await this.assertNativeSourcesProven(manifest, options.projectRoot);
// Undoing a host's own registration must happen before any purge: a host's own CLI resolves
// what it is unregistering against the built tree still on disk, and the purge below removes
// exactly that tree. Absent a manifest there is nothing recorded to undo.
@@ -115,6 +150,23 @@ export class CleanUserScopeUseCase {
private async buildPreview(manifest: Manifest | null): Promise {
return {
toolIds: manifest?.getInstalledToolIds() ?? [],
+ activePluginDependents: [
+ ...new Set(
+ manifest?.getInstalledToolIds().flatMap((toolId) =>
+ isAiToolId(toolId)
+ ? [
+ ...manifest
+ .getPlugins(toolId)
+ .filter((plugin) => plugin.scope === "user")
+ .flatMap((plugin) => plugin.dependents),
+ ...(manifest.getNativeRegistrations(toolId)?.pluginClaims ?? []).flatMap(
+ (claim) => claim.dependents
+ ),
+ ]
+ : []
+ ) ?? []
+ ),
+ ],
builtVersions: await this.listBuiltVersions(),
referencingProjects: await this.listReferencingProjects(),
};
@@ -150,10 +202,28 @@ export class CleanUserScopeUseCase {
private async listReferencingProjects(): Promise {
if (this.userSourceReferences === undefined) return [];
- const userSourceReferences = this.userSourceReferences;
- return toleratingUnreadableSourceReferences(this.logger, [], () =>
- userSourceReferences.listAllReferencingProjects()
- );
+ return this.userSourceReferences.listAllReferencingProjects();
+ }
+
+ private async assertTrackedUserPluginFilesSafe(manifest: Manifest): Promise {
+ for (const toolId of manifest.getInstalledToolIds()) {
+ if (!isAiToolId(toolId)) continue;
+ for (const plugin of manifest.getPlugins(toolId)) {
+ if (plugin.scope !== "user") continue;
+ const safe = await userScopeFilesSafeToDelete(
+ this.fs,
+ this.logger,
+ plugin,
+ toolId,
+ this.homeDir()
+ );
+ if (safe.size !== plugin.files.size) {
+ throw new Error(
+ `User clean refused: a tracked file of '${plugin.name}' escaped its user-scope boundary; canonical claims retained.`
+ );
+ }
+ }
+ }
}
private async confirmOrDryRun(
@@ -196,6 +266,79 @@ export class CleanUserScopeUseCase {
return undone;
}
+ private assertNativeActivatorsAvailable(manifest: Manifest): void {
+ for (const toolId of manifest.getInstalledToolIds()) {
+ const registrations = manifest.getNativeRegistrations(toolId);
+ if (registrations === undefined) continue;
+ const activator = this.activators.get(registrations.binary);
+ if (activator === undefined || !activator.isAvailable())
+ throw new Error(
+ `${this.describeBinaryAbsent(toolId, registrations)} User clean refused; canonical claims retained.`
+ );
+ }
+ }
+
+ private async assertNativeSourcesProven(manifest: Manifest, projectRoot: string): Promise {
+ for (const toolId of manifest.getInstalledToolIds()) {
+ const registrations = manifest.getNativeRegistrations(toolId);
+ if (registrations === undefined) continue;
+ if (
+ !isAiToolId(toolId) ||
+ ((registrations.pluginClaims?.length ?? 0) > 0 && registrations.marketplaces.length === 0)
+ )
+ throw new Error(
+ `${toolId}: native refs lack a canonical catalogue source; user clean refused.`
+ );
+ const hostNames = new Set();
+ for (const { hostName } of registrations.marketplaces) {
+ if (hostNames.has(hostName))
+ throw new Error(
+ `${toolId}: ambiguous canonical host catalogue '${hostName}'; user clean refused.`
+ );
+ hostNames.add(hostName);
+ }
+ const claims = registrations.pluginClaims ?? [];
+ const claimRefs = new Set(claims.map((claim) => claim.ref));
+ if (claimRefs.size !== claims.length)
+ throw new Error(`${toolId}: duplicate canonical native ref claims; user clean refused.`);
+ const refsByHost = new Map>(
+ [...hostNames].map((hostName) => [hostName, new Set()])
+ );
+ for (const ref of new Set([...registrations.pluginRefs, ...claimRefs])) {
+ const matches = [...hostNames].filter((hostName) => ref.endsWith(`@${hostName}`));
+ if (matches.length !== 1)
+ throw new Error(
+ `${toolId}: native ref '${ref}' lacks exactly one canonical catalogue; user clean refused.`
+ );
+ refsByHost.get(matches[0])?.add(ref);
+ }
+ for (const registration of registrations.marketplaces) {
+ const proof = await inspectNativeMarketplaceSource(
+ this.nativeSources.get(toolId),
+ projectRoot,
+ registration
+ );
+ if (proof.status !== "owned")
+ throw new Error(proof.reason ?? `${toolId}: catalogue source unproven.`);
+ const owned = new Set(
+ [...claimRefs].filter((ref) => ref.endsWith(`@${registration.hostName}`))
+ );
+ const hostRefs = await assertNoForeignNativeRefs(
+ this.hostPluginRegistries.get(toolId),
+ registration.hostName,
+ owned,
+ projectRoot
+ );
+ for (const ref of refsByHost.get(registration.hostName) ?? []) {
+ if (hostRefs.get(ref)?.enabled !== true)
+ throw new Error(
+ `${toolId}: owned host ref '${ref}' is not enabled; user clean refused.`
+ );
+ }
+ }
+ }
+ }
+
private async undoToolNativeRegistrations(
toolId: ToolId,
registrations: NativeRegistrations
@@ -203,15 +346,20 @@ export class CleanUserScopeUseCase {
const { binary } = registrations;
const activator = this.activators.get(binary);
if (activator === undefined || !activator.isAvailable()) {
- this.logger.warn(this.describeBinaryAbsent(toolId, registrations));
- return undefined;
+ throw new Error(
+ `${this.describeBinaryAbsent(toolId, registrations)} User clean refused; canonical claims retained.`
+ );
}
- for (const ref of registrations.pluginRefs) {
- bestEffortNativeCall(
+ for (const { ref } of registrations.pluginClaims ?? []) {
+ const removed = bestEffortNativeCall(
this.logger,
() => activator.uninstallPlugin(ref, "user"),
`${binary} plugin uninstall '${ref}'`
);
+ if (!removed)
+ throw new Error(
+ `${binary}: user clean refused after plugin uninstall '${ref}' failed; canonical claims retained.`
+ );
}
const removedHostNames = new Set();
for (const { hostName } of registrations.marketplaces) {
@@ -220,7 +368,11 @@ export class CleanUserScopeUseCase {
() => activator.removeMarketplace(hostName, "user"),
`${binary} marketplace remove '${hostName}'`
);
- if (removed) removedHostNames.add(hostName);
+ if (!removed)
+ throw new Error(
+ `${binary}: user clean refused after marketplace remove '${hostName}' failed; canonical claims retained.`
+ );
+ removedHostNames.add(hostName);
}
return removedHostNames;
}
@@ -233,7 +385,7 @@ export class CleanUserScopeUseCase {
const base =
`${binary}: registration left in place, the ${binary} CLI is not on the PATH. ` +
`It would have unregistered ${registrations.marketplaces.length} marketplace(s) ` +
- `and ${registrations.pluginRefs.length} plugin ref(s).`;
+ `and ${registrations.pluginClaims?.length ?? 0} plugin ref(s).`;
if (!isAiToolId(toolId)) return base;
const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(this.homeDir());
if (cacheRoot === undefined) return base;
@@ -258,7 +410,7 @@ export class CleanUserScopeUseCase {
toolId,
this.homeDir()
);
- await deletePluginFilesForTool(files, plugin.scope, toolId, projectRoot, this.fs);
+ await deletePluginFilesForTool(files, plugin.scope, toolId, projectRoot, this.fs, "user");
}
}
}
@@ -288,8 +440,8 @@ export class CleanUserScopeUseCase {
);
// The manifest's own repository is the single writer of `manifest.json` — deleting through
// it, never a second path to the same file, is what keeps that true.
- await this.userManifestRepo.delete();
await this.marketplaceRegistry.delete(projectRoot, FRAMEWORK_MARKETPLACE_NAME, "user");
+ await this.userManifestRepo.delete();
}
/** `cache/built/` and `cache/update-check.json` are this whitelist's only occupants of
diff --git a/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts
index 5f5f0fb22..3655045ad 100644
--- a/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts
+++ b/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts
@@ -2,8 +2,8 @@ import {
InvalidMarketplaceNameError,
MarketplaceNotFoundError,
} from "../../../../kernel/errors.js";
-import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
import type { Prompter } from "../../../../kernel/ports/prompter.js";
+import type { ToolId } from "../../../../kernel/tool.js";
import { AI_TOOL_IDS, type AiToolId } from "../../../../kernel/tool.js";
import {
FRAMEWORK_MARKETPLACE_NAME,
@@ -13,12 +13,13 @@ import type { MarketplaceRegistry } from "../../../distribution/domain/ports/mar
import type { Manifest } from "../../domain/manifest.js";
import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js";
import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
-import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js";
+import type { ProjectPluginCleanup } from "../ownership/project-plugin-cleanup.js";
export interface MarketplaceRemoveOptions {
name: string;
projectRoot: string;
autoConfirm: boolean;
+ scope?: "project" | "user";
}
export interface MarketplaceRemoveResult {
@@ -34,7 +35,7 @@ interface OrphanRef {
export class MarketplaceRemoveUseCase {
constructor(
- private readonly fs: FileWriter,
+ private readonly cleanup: ProjectPluginCleanup,
private readonly manifestRepo: ManifestRepository,
private readonly registry: MarketplaceRegistry,
private readonly prompter: Prompter
@@ -49,21 +50,40 @@ export class MarketplaceRemoveUseCase {
`"${FRAMEWORK_MARKETPLACE_NAME}" is shared by every project on this machine and is not removed with \`aidd marketplace remove\` — it is removed with the framework itself, by \`aidd clean\`, once machine scope lands there.`
);
}
- const marketplace = await this.findOrThrow(options.projectRoot, options.name);
+ if (options.scope === "user")
+ throw new Error("User-scope marketplace removal requires UserMarketplaceRemoveUseCase.");
+ const marketplace = await this.findOrThrow(options.projectRoot, options.name, "project");
const manifest = await this.manifestRepo.load();
const orphans = manifest ? this.collectOrphans(manifest, options.name) : [];
+ const nativeRefs = new Map();
+ if (manifest !== null) {
+ for (const { toolId, plugin } of orphans) {
+ const hostName = manifest
+ .getNativeRegistrations(toolId)
+ ?.marketplaces.find((m) => m.alias === plugin.marketplace)?.hostName;
+ if (hostName !== undefined)
+ nativeRefs.set(toolId, [...(nativeRefs.get(toolId) ?? []), `${plugin.name}@${hostName}`]);
+ }
+ }
const cleanup = await this.shouldCleanup(orphans.length, options.autoConfirm);
let removed = 0;
if (cleanup && manifest) {
removed = await this.removeOrphans(manifest, orphans, options.projectRoot);
}
await this.registry.delete(options.projectRoot, marketplace.name, marketplace.scope);
+ if (cleanup) {
+ await this.cleanup.detachClaims(options.projectRoot, nativeRefs, orphans);
+ }
return { marketplace, removedPluginCount: removed, orphanCount: orphans.length };
}
- private async findOrThrow(projectRoot: string, name: string): Promise {
+ private async findOrThrow(
+ projectRoot: string,
+ name: string,
+ scope: "project" | "user"
+ ): Promise {
const list = await this.registry.list(projectRoot);
- const found = list.find((m) => m.name === name);
+ const found = list.find((m) => m.name === name && m.scope === scope);
if (!found) throw new MarketplaceNotFoundError(name);
return found;
}
@@ -90,7 +110,22 @@ export class MarketplaceRemoveUseCase {
projectRoot: string
): Promise {
for (const { toolId, plugin } of orphans) {
- await deletePluginFilesForTool(plugin.files, plugin.scope, toolId, projectRoot, this.fs);
+ await this.cleanup.assertLocalIntegrationRemovable(toolId, plugin, projectRoot);
+ }
+ for (const { toolId, plugin } of orphans) {
+ await this.cleanup.removeLocalIntegration(toolId, plugin, projectRoot);
+ await this.cleanup.deleteLocalFiles(toolId, plugin, projectRoot);
+ const registrations = manifest.getNativeRegistrations(toolId);
+ const hostName = registrations?.marketplaces.find(
+ (m) => m.alias === plugin.marketplace
+ )?.hostName;
+ if (registrations !== undefined && hostName !== undefined)
+ manifest.setNativeRegistrations(toolId, {
+ ...registrations,
+ pluginRefs: registrations.pluginRefs.filter(
+ (ref) => ref !== `${plugin.name}@${hostName}`
+ ),
+ });
manifest.removePlugin(toolId, plugin.name);
}
await this.manifestRepo.save(manifest);
diff --git a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts
index fa9962f09..458151b01 100644
--- a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts
+++ b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts
@@ -4,7 +4,13 @@ import {
NativePluginCliError,
UnreadableBuiltCatalogError,
} from "../../../../kernel/errors.js";
-import { BUILT_CACHE_SUBDIR } from "../../../../kernel/paths.js";
+import {
+ BUILT_CACHE_SUBDIR,
+ parseBuiltMarketplaceDir,
+ parseUserBuiltMarketplaceDir,
+ samePath,
+ samePathSegment,
+} from "../../../../kernel/paths.js";
import type { FileReader } from "../../../../kernel/ports/file-reader.js";
import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
import type { Hasher } from "../../../../kernel/ports/hasher.js";
@@ -25,16 +31,29 @@ import {
pluginSetDifference,
} from "../../../tools/domain/marketplace-source-conflict.js";
import type { HostMarketplaceRegistryReader } from "../../../tools/domain/ports/host-marketplace-registry-reader.js";
+import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js";
+import type {
+ NativeMarketplaceSource,
+ NativeMarketplaceSourceReader,
+} from "../../../tools/domain/ports/native-marketplace-source-reader.js";
import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js";
-import { nativeActivationOf, resolvePluginsCapability } from "../../../tools/domain/registry.js";
+import {
+ nativeActivationOf,
+ pluginEnablementIsMachineGlobal,
+ resolvePluginsCapability,
+} from "../../../tools/domain/registry.js";
import type { FrameworkBuildTarget } from "../../../translate/domain/build-target.js";
import type {
NativeMarketplaceRegistration,
NativeRegistrations,
} from "../../domain/manifest/native-registrations.js";
-import type { Manifest } from "../../domain/manifest.js";
+import { Manifest } from "../../domain/manifest.js";
import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
import type { UserSourceReferences } from "../../domain/ports/user-source-references.js";
+import {
+ inspectNativeMarketplaceSource,
+ sameNativeMarketplaceSource,
+} from "../ownership/native-marketplace-source-proof.js";
import type { EnsureBuiltMarketplace } from "../shared/ensure-built-marketplace-use-case.js";
import {
hostMarketplaceSourceConflict,
@@ -80,12 +99,24 @@ export interface MarketplaceSyncSettingsOptions {
interface ActivationOutcome {
marketplaces: readonly NativeMarketplaceRegistration[];
+ preservedMarketplaces: readonly NativeMarketplaceRegistration[];
+ catalogClaims: readonly NativeMarketplaceRegistration[];
pluginRefs: readonly string[];
/** A marketplace whose build failed was warned about and left unregistered this run — the
* host's own registration for it is wherever it was before. */
buildFailed: boolean;
}
+interface NativeCatalogProof {
+ readonly canonical: boolean;
+ readonly hostReadable: boolean;
+ readonly sourceStatus: "absent" | "owned" | "unproven";
+ readonly source?: NativeMarketplaceSource;
+ readonly sourceReason?: string;
+ readonly foreignRef?: string;
+ readonly unreadable?: string;
+}
+
export interface MarketplaceSyncSettingsResult {
/** Tools whose own CLI actually ran, whether or not every step inside it succeeded. */
activated: readonly ToolId[];
@@ -143,10 +174,28 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
* Absent keeps the silent no-op instead of guessing what to register. */
private readonly marketplaceRegisterFrameworkUseCase?: MarketplaceRegisterFramework,
private readonly userSourceReferences?: UserSourceReferences,
- private readonly currentVersionProvider?: VersionReader
+ private readonly currentVersionProvider?: VersionReader,
+ private readonly hostPluginRegistries: ReadonlyMap<
+ AiToolId,
+ HostPluginRegistryReader
+ > = new Map(),
+ private readonly userManifestRepo?: ManifestRepository,
+ private readonly nativeSources: ReadonlyMap = new Map()
) {}
async execute(options: MarketplaceSyncSettingsOptions): Promise {
+ if (
+ (options.scope ?? "project") === "project" &&
+ this.userManifestRepo?.withExclusiveAccess !== undefined
+ ) {
+ return this.userManifestRepo.withExclusiveAccess(() => this.executeLocked(options));
+ }
+ return this.executeLocked(options);
+ }
+
+ private async executeLocked(
+ options: MarketplaceSyncSettingsOptions
+ ): Promise {
const { projectRoot } = options;
const manifestRepo = options.manifestRepo ?? this.manifestRepo;
const scope = options.scope ?? "project";
@@ -163,14 +212,16 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
? recreatedMarketplaces
: recreatedMarketplaces.filter((m) => options.marketplaceNames?.includes(m.name));
if (marketplaces.length === 0) return EMPTY_RESULT;
- // A user-scope run has no project-scope manifest for a later `clean` to decrement this
- // claim from.
- if (scope !== "user") await this.recordSharedSourceReference(projectRoot, marketplaces);
const toolIds = this.selectToolIds(manifest, options.toolIds);
let anyToolUpdated = false;
// A user-scope run lands nothing under `projectRoot`, so no project settings file mirrors it.
if (scope === "project") {
for (const toolId of toolIds) {
+ if (
+ resolvePluginsCapability(toolId)?.marketplaceSettings
+ ?.declarativePluginActivationRequiresNativeProof === true
+ )
+ continue;
if (await this.syncTool(toolId, projectRoot, manifest, marketplaces)) anyToolUpdated = true;
}
}
@@ -182,6 +233,35 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
toolIds,
scope
);
+ let declarativeChanged = false;
+ if (scope === "project") {
+ for (const [toolId, outcome] of activation.outcomes) {
+ const settings = resolvePluginsCapability(toolId)?.marketplaceSettings;
+ if (settings?.declarativePluginActivationRequiresNativeProof !== true) continue;
+ const hostNames = new Set(outcome.marketplaces.map((entry) => entry.hostName));
+ const provenRefs = outcome.pluginRefs.filter((ref) =>
+ [...hostNames].some((hostName) => ref.endsWith(`@${hostName}`))
+ );
+ if (
+ provenRefs.length > 0 &&
+ (await this.syncEnabledPluginsFile(
+ toolId,
+ projectRoot,
+ manifest,
+ marketplaces,
+ settings,
+ provenRefs
+ ))
+ )
+ declarativeChanged = true;
+ }
+ }
+ if (declarativeChanged) await manifestRepo.save(manifest);
+ // A refused or unproven host registration cannot establish a new shared-source claim.
+ // User-scope runs have no project manifest for a later `clean` to decrement it.
+ if (scope !== "user") {
+ await this.recordSharedSourceReference(projectRoot, marketplaces, activation.outcomes);
+ }
const wroteHashes = await this.recordWhatActivationWrote(projectRoot, manifest, [
...activation.outcomes.keys(),
]);
@@ -191,6 +271,13 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
options.marketplaceNames !== undefined
);
if (wroteHashes || wroteRegistrations) await manifestRepo.save(manifest);
+ await this.recordMachinePluginClaims(
+ manifest,
+ activation.outcomes,
+ marketplaces,
+ scope,
+ scope === "project" ? projectRoot : undefined
+ );
if (options.recreateFrameworkIfMissing === true && scope === "project") {
await this.purgeStaleProjectCache(projectRoot, marketplaces, activation);
}
@@ -266,18 +353,24 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
}
/**
- * Refreshed on every run that finds the shared source registered, not only the one that had to
- * recreate it: this project's own reference is still missing the first time its `sync` runs here.
+ * Refreshed only after native activation proves that the host uses the shared source.
*/
private async recordSharedSourceReference(
projectRoot: string,
- marketplaces: readonly Marketplace[]
+ marketplaces: readonly Marketplace[],
+ outcomes: ReadonlyMap
): Promise {
if (this.userSourceReferences === undefined || this.currentVersionProvider === undefined) {
return;
}
const framework = marketplaces.find((m) => frameworkSourceIsShared(m.name, m.scope));
if (framework === undefined) return;
+ if (
+ ![...outcomes.values()].some((outcome) =>
+ outcome.marketplaces.some((registration) => registration.alias === framework.name)
+ )
+ )
+ return;
await this.recordReferenceForRoot(projectRoot);
}
@@ -358,8 +451,9 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
const binary = this.nativeActivationBinary(toolId);
if (binary === undefined) continue;
const existing = manifest.getNativeRegistrations(toolId);
- const touchedAliases = new Set(outcome.marketplaces.map((m) => m.alias));
- const touchedHostNames = new Set(outcome.marketplaces.map((m) => m.hostName));
+ const projected = [...outcome.marketplaces, ...outcome.preservedMarketplaces];
+ const touchedAliases = new Set(projected.map((m) => m.alias));
+ const touchedHostNames = new Set(projected.map((m) => m.hostName));
const retainedMarketplaces = narrowed
? (existing?.marketplaces ?? []).filter((m) => !touchedAliases.has(m.alias))
: [];
@@ -372,10 +466,15 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
)
)
: [];
+ const preservedHostNames = new Set(outcome.preservedMarketplaces.map((m) => m.hostName));
+ const preservedRefs = (existing?.pluginRefs ?? []).filter((ref) =>
+ [...preservedHostNames].some((hostName) => ref.endsWith(`@${hostName}`))
+ );
const registrations: NativeRegistrations = {
binary,
- marketplaces: [...retainedMarketplaces, ...outcome.marketplaces],
- pluginRefs: [...new Set([...retainedRefs, ...outcome.pluginRefs])],
+ marketplaces: [...retainedMarketplaces, ...projected],
+ pluginRefs: [...new Set([...retainedRefs, ...preservedRefs, ...outcome.pluginRefs])],
+ ...(existing?.pluginClaims === undefined ? {} : { pluginClaims: existing.pluginClaims }),
};
if (nativeRegistrationsEqual(existing, registrations)) continue;
manifest.setNativeRegistrations(toolId, registrations);
@@ -438,7 +537,11 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
//
// Each step is independently best-effort: one failing plugin or marketplace must warn and
// let the others through, never abort the whole activation.
- const registeredMarketplaces: NativeMarketplaceRegistration[] = [];
+ const ownMarketplaces: NativeMarketplaceRegistration[] = [];
+ const preservedMarketplaces: NativeMarketplaceRegistration[] = [];
+ const catalogClaims: NativeMarketplaceRegistration[] = [];
+ const addedHostNames = new Set();
+ const recorded = manifest.getNativeRegistrations(toolId)?.marketplaces;
let buildFailed = false;
for (const marketplace of marketplaces) {
const registration = await this.registerMarketplace(
@@ -446,30 +549,206 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
toolId,
marketplace,
projectRoot,
- warnings
+ warnings,
+ catalogClaims
);
- if (!registration.registered) buildFailed = true;
- registeredMarketplaces.push({ alias: marketplace.name, hostName: registration.hostName });
+ if (registration.outcome === "build-failed") buildFailed = true;
+ if (registration.addedThisRun === true) addedHostNames.add(registration.hostName);
+ const entry = {
+ alias: marketplace.name,
+ hostName: registration.hostName,
+ ...(registration.provenance === undefined ? {} : { provenance: registration.provenance }),
+ };
+ const earlier = recorded?.find((m) => m.alias === marketplace.name);
+ if (registration.outcome === "registered" && registration.claimable)
+ ownMarketplaces.push(entry);
+ if (registration.claimable) catalogClaims.push(entry);
+ else if (earlier !== undefined) {
+ const proof = await this.nativeCatalogProof(toolId, earlier.hostName, projectRoot);
+ if (
+ proof.canonical &&
+ proof.hostReadable &&
+ proof.sourceStatus === "owned" &&
+ proof.foreignRef === undefined
+ )
+ ownMarketplaces.push(earlier);
+ else preservedMarketplaces.push(earlier);
+ }
}
if (!activator.enablesPlugins())
- return { marketplaces: registeredMarketplaces, pluginRefs: [], buildFailed };
- this.bestEffort(() => activator.upgradeMarketplaces(), "upgrade marketplaces", warnings);
- const hostNameByAlias = new Map(registeredMarketplaces.map((m) => [m.alias, m.hostName]));
- const refs = this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias);
+ return {
+ marketplaces: ownMarketplaces,
+ preservedMarketplaces,
+ catalogClaims,
+ pluginRefs: [],
+ buildFailed,
+ };
+ const stillProven: NativeMarketplaceRegistration[] = [];
+ for (const registration of ownMarketplaces) {
+ const fresh = catalogClaims.find((claim) => claim.hostName === registration.hostName);
+ const proof = await this.nativeCatalogProof(
+ toolId,
+ registration.hostName,
+ projectRoot,
+ fresh
+ );
+ if (
+ !proof.canonical ||
+ !proof.hostReadable ||
+ proof.sourceStatus !== "owned" ||
+ proof.foreignRef !== undefined
+ )
+ continue;
+ stillProven.push(registration);
+ if (!addedHostNames.has(registration.hostName))
+ this.bestEffort(
+ () => activator.upgradeMarketplaces(registration.hostName),
+ `upgrade marketplace '${registration.hostName}'`,
+ warnings
+ );
+ }
+ const hostNameByAlias = new Map(stillProven.map((m) => [m.alias, m.hostName]));
+ const { refsToEnable, alreadyOwnedRefs } = await this.refsThisProjectEnables(
+ toolId,
+ this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias),
+ manifest,
+ projectRoot
+ );
+ const enabledRefs = [...alreadyOwnedRefs];
+ for (const ref of refsToEnable) {
+ if (
+ this.bestEffort(
+ () => activator.enablePlugin(ref, scope),
+ `enable plugin '${ref}'`,
+ warnings
+ )
+ ) {
+ enabledRefs.push(ref);
+ }
+ }
+ return {
+ marketplaces: ownMarketplaces,
+ preservedMarketplaces,
+ catalogClaims,
+ pluginRefs: enabledRefs,
+ buildFailed,
+ };
+ }
+
+ private async refsThisProjectEnables(
+ toolId: ToolId,
+ refs: string[],
+ manifest: Manifest,
+ projectRoot: string
+ ): Promise<{ refsToEnable: string[]; alreadyOwnedRefs: string[] }> {
+ const hostRegistry = isAiToolId(toolId) ? this.hostPluginRegistries.get(toolId) : undefined;
+ if (hostRegistry === undefined) return { refsToEnable: refs, alreadyOwnedRefs: [] };
+ const ownRefs = manifest.getNativeRegistrations(toolId)?.pluginRefs;
+ const onHost = (await hostRegistry.read(projectRoot)).refs;
+ const machine = await this.userManifestRepo?.load();
+ const machineClaims = machine?.getNativeRegistrations(toolId)?.pluginClaims ?? [];
+ const refsToEnable: string[] = [];
+ const alreadyOwnedRefs: string[] = [];
for (const ref of refs) {
- this.bestEffort(() => activator.enablePlugin(ref, scope), `enable plugin '${ref}'`, warnings);
+ if (onHost?.get(ref)?.enabled !== true) {
+ refsToEnable.push(ref);
+ continue;
+ }
+ if (machineClaims.some((claim) => claim.ref === ref)) {
+ alreadyOwnedRefs.push(ref);
+ continue;
+ }
+ if (ownRefs?.includes(ref) === true) {
+ if (!pluginEnablementIsMachineGlobal(toolId) || this.userManifestRepo === undefined)
+ alreadyOwnedRefs.push(ref);
+ continue;
+ }
+ this.logger.info(
+ `${toolId}: '${ref}' was already enabled before this project asked for it — left as it is, and this project's clean will leave it enabled.`
+ );
}
- return { marketplaces: registeredMarketplaces, pluginRefs: refs, buildFailed };
+ return { refsToEnable, alreadyOwnedRefs };
}
- private bestEffort(action: () => void, label: string, warnings: string[]): void {
+ private async recordMachinePluginClaims(
+ project: Manifest,
+ outcomes: ReadonlyMap,
+ marketplaces: readonly Marketplace[],
+ scope: MarketplaceScope,
+ projectRoot?: string
+ ): Promise {
+ if (this.userManifestRepo === undefined) return;
+ const machine = (await this.userManifestRepo.load()) ?? Manifest.create();
+ const root = projectRoot === undefined ? undefined : await this.fs.realpath(projectRoot);
+ let changed = false;
+ for (const [toolId, outcome] of outcomes) {
+ const machineRefs =
+ scope === "user" || pluginEnablementIsMachineGlobal(toolId) ? outcome.pluginRefs : [];
+ const machineMarketplaces = outcome.catalogClaims.filter((registration) =>
+ marketplaces.some(
+ (marketplace) =>
+ marketplace.name === registration.alias &&
+ (marketplace.scope === "user" || pluginEnablementIsMachineGlobal(toolId))
+ )
+ );
+ if (machineRefs.length === 0 && machineMarketplaces.length === 0) continue;
+ if (!machine.hasTool(toolId)) {
+ const version = project.getToolVersion(toolId);
+ if (version === undefined) continue;
+ machine.addTool(toolId, version, []);
+ }
+ const existing = machine.getNativeRegistrations(toolId);
+ const registered = [...(existing?.marketplaces ?? [])];
+ for (const registration of machineMarketplaces) {
+ const index = registered.findIndex(
+ (entry) => entry.alias === registration.alias && entry.hostName === registration.hostName
+ );
+ if (index >= 0) {
+ if (
+ registration.provenance === undefined ||
+ (registered[index].provenance !== undefined &&
+ sameNativeMarketplaceSource(registered[index].provenance, registration.provenance))
+ )
+ continue;
+ registered[index] = registration;
+ } else registered.push(registration);
+ changed = true;
+ }
+ const claims = [...(existing?.pluginClaims ?? [])].map((claim) => ({
+ ref: claim.ref,
+ dependents: [...claim.dependents],
+ }));
+ for (const ref of machineRefs) {
+ const claim = claims.find((candidate) => candidate.ref === ref);
+ if (claim === undefined) {
+ claims.push({ ref, dependents: root === undefined ? [] : [root] });
+ changed = true;
+ } else if (root !== undefined && !claim.dependents.includes(root)) {
+ claim.dependents.push(root);
+ changed = true;
+ }
+ }
+ if (changed)
+ machine.setNativeRegistrations(toolId, {
+ binary: existing?.binary ?? this.nativeActivationBinary(toolId) ?? toolId,
+ marketplaces: registered,
+ pluginRefs: existing?.pluginRefs ?? [],
+ pluginClaims: claims,
+ });
+ }
+ if (changed) await this.userManifestRepo.save(machine);
+ }
+
+ private bestEffort(action: () => void, label: string, warnings: string[]): boolean {
try {
action();
+ return true;
} catch (error) {
if (!(error instanceof NativePluginCliError)) throw error;
const message = `Native plugin activation — ${label} skipped: ${error.message}`;
this.logger.warn(message);
warnings.push(message);
+ return false;
}
}
@@ -494,7 +773,61 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
return refs;
}
- // Native tools must read the BUILT (transformed) tree, not the raw Claude-format source.
+ private async nativeCatalogProof(
+ toolId: ToolId,
+ hostName: string,
+ projectRoot: string,
+ fresh?: NativeMarketplaceRegistration
+ ): Promise {
+ if (!isAiToolId(toolId))
+ return { canonical: false, hostReadable: false, sourceStatus: "unproven" };
+ const machine = await this.userManifestRepo?.load();
+ const registrations = machine?.getNativeRegistrations(toolId);
+ const project = await this.manifestRepo.load();
+ const localRegistration = pluginEnablementIsMachineGlobal(toolId)
+ ? undefined
+ : project
+ ?.getNativeRegistrations(toolId)
+ ?.marketplaces.find((entry) => entry.hostName === hostName);
+ const canonical =
+ fresh !== undefined ||
+ registrations?.marketplaces.some((entry) => entry.hostName === hostName) === true ||
+ localRegistration !== undefined;
+ const registration = fresh ??
+ registrations?.marketplaces.find((entry) => entry.hostName === hostName) ??
+ localRegistration ?? {
+ alias: hostName,
+ hostName,
+ };
+ const sourceProof = await inspectNativeMarketplaceSource(
+ this.nativeSources.get(toolId),
+ projectRoot,
+ registration
+ );
+ const reader = this.hostPluginRegistries.get(toolId);
+ const base = {
+ canonical,
+ sourceStatus: sourceProof.status,
+ ...(sourceProof.current === undefined ? {} : { source: sourceProof.current }),
+ ...(sourceProof.reason === undefined ? {} : { sourceReason: sourceProof.reason }),
+ };
+ if (reader === undefined) return { ...base, hostReadable: false };
+ const reading = await reader.read(projectRoot);
+ if (reading.absent === true) return { ...base, hostReadable: true };
+ if (reading.refs === undefined)
+ return { ...base, hostReadable: false, unreadable: reading.unreadable ?? reading.location };
+ const claimed = new Set([
+ ...(registrations?.pluginClaims ?? []).map((claim) => claim.ref),
+ ...(pluginEnablementIsMachineGlobal(toolId)
+ ? []
+ : (project?.getNativeRegistrations(toolId)?.pluginRefs ?? [])),
+ ]);
+ const foreignRef = [...reading.refs.keys()].find(
+ (ref) => ref.endsWith(`@${hostName}`) && !claimed.has(ref)
+ );
+ return { ...base, hostReadable: true, ...(foreignRef === undefined ? {} : { foreignRef }) };
+ }
+
// Returns the host's own catalog name, never this project's local alias: a catalog this
// project just built and cannot read back is not registered at all (see the throw below).
private async registerMarketplace(
@@ -502,10 +835,18 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
toolId: ToolId,
marketplace: Marketplace,
projectRoot: string,
- warnings: string[]
- ): Promise<{ hostName: string; registered: boolean }> {
+ warnings: string[],
+ freshClaims: readonly NativeMarketplaceRegistration[]
+ ): Promise<{
+ hostName: string;
+ outcome: "registered" | "refused" | "build-failed";
+ claimable: boolean;
+ addedThisRun?: boolean;
+ provenance?: NativeMarketplaceSource;
+ }> {
const builtDir = await this.buildForTool(toolId, marketplace, projectRoot);
- if (builtDir === null) return { hostName: marketplace.name, registered: false };
+ if (builtDir === null)
+ return { hostName: marketplace.name, outcome: "build-failed", claimable: false };
const requestedIdentity = await readMarketplaceCatalogIdentity(this.fs, toolId, builtDir);
if (requestedIdentity === undefined) {
throw new UnreadableBuiltCatalogError(
@@ -513,6 +854,81 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
);
}
const hostName = requestedIdentity.name;
+ const proof = await this.nativeCatalogProof(
+ toolId,
+ hostName,
+ projectRoot,
+ freshClaims.find((claim) => claim.hostName === hostName)
+ );
+ if (
+ proof.sourceStatus === "unproven" ||
+ (proof.sourceStatus === "owned" && proof.source === undefined)
+ ) {
+ if (isAiToolId(toolId) && nativeActivationOf(toolId)?.marketplaceRegistry !== undefined) {
+ const existing = await this.hostMarketplaceRegistries.get(toolId)?.read();
+ if (existing?.entries?.has(hostName) === true)
+ await this.guardAgainstConflict(
+ toolId,
+ builtDir,
+ requestedIdentity,
+ marketplace,
+ projectRoot,
+ warnings
+ );
+ }
+ const message = `${toolId}: catalogue '${hostName}' host source unproven (${proof.sourceReason ?? "unknown"}); registration left untouched.`;
+ this.logger.warn(message);
+ warnings.push(message);
+ return { hostName, outcome: "refused", claimable: false };
+ }
+ const hostMarketplaceReading =
+ isAiToolId(toolId) && nativeActivationOf(toolId)?.marketplaceRegistry !== undefined
+ ? await this.hostMarketplaceRegistries.get(toolId)?.read()
+ : undefined;
+ if (hostMarketplaceReading?.entries?.has(hostName) === true && !proof.canonical) {
+ await this.guardAgainstConflict(
+ toolId,
+ builtDir,
+ requestedIdentity,
+ marketplace,
+ projectRoot,
+ warnings
+ );
+ const message = `${toolId}: catalogue '${hostName}' is already registered on the host without a canonical AIDD claim; same-name collision left untouched.`;
+ this.logger.warn(message);
+ warnings.push(message);
+ return { hostName, outcome: "refused", claimable: false };
+ }
+ if (
+ hostMarketplaceReading !== undefined &&
+ hostMarketplaceReading.entries === undefined &&
+ hostMarketplaceReading.absent !== true
+ ) {
+ const message = `${toolId}: catalogue '${hostName}' host registry is unreadable; ownership unproven and registration left untouched.`;
+ this.logger.warn(message);
+ warnings.push(message);
+ return { hostName, outcome: "refused", claimable: false };
+ }
+ if (!proof.hostReadable || proof.foreignRef !== undefined || proof.unreadable !== undefined) {
+ const message =
+ proof.foreignRef === undefined
+ ? `${toolId}: catalogue '${hostName}' collides with unreadable host plugin registry (${proof.unreadable}); registration left untouched.`
+ : `${toolId}: catalogue '${hostName}' carries foreign host ref '${proof.foreignRef}'; registration left untouched.`;
+ this.logger.warn(message);
+ warnings.push(message);
+ return { hostName, outcome: "refused", claimable: false };
+ }
+ const requestedSource = await this.fs.realpath(builtDir).catch(() => builtDir);
+ if (
+ proof.sourceStatus === "owned" &&
+ proof.source?.source !== undefined &&
+ samePath(proof.source.source, requestedSource) &&
+ (proof.source.kind === "registry" ||
+ (proof.source.kind === "effective-list" &&
+ samePath(proof.source.root, requestedSource) &&
+ proof.source.sourceType === "local"))
+ )
+ return { hostName, outcome: "registered", claimable: true, provenance: proof.source };
const decision = await this.guardAgainstConflict(
toolId,
builtDir,
@@ -523,14 +939,75 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
);
// A "skip" is a host already following a newer shared build, never this project's own
// pre-migration cache, so it counts as registered.
- if (decision === "skip") return { hostName, registered: true };
+ if (decision === "skip")
+ return {
+ hostName,
+ outcome: "registered",
+ claimable: proof.sourceStatus === "owned",
+ provenance: proof.source,
+ };
try {
activator.addMarketplace(builtDir, marketplace.scope);
} catch (error) {
if (!(error instanceof NativePluginCliError)) throw error;
- this.reclaimOrReport(toolId, activator, marketplace, hostName, builtDir, error, warnings);
+ const reclaimed = await this.reclaimOrReport(
+ toolId,
+ activator,
+ marketplace,
+ hostName,
+ builtDir,
+ projectRoot,
+ error,
+ warnings
+ );
+ if (!reclaimed) return { hostName, outcome: "refused", claimable: false };
+ const afterReclaim = await this.proveAddedSource(
+ toolId,
+ hostName,
+ builtDir,
+ projectRoot,
+ warnings
+ );
+ return {
+ hostName,
+ outcome: afterReclaim === undefined ? "refused" : "registered",
+ claimable: afterReclaim !== undefined,
+ addedThisRun: afterReclaim !== undefined,
+ provenance: afterReclaim,
+ };
}
- return { hostName, registered: true };
+ const afterAdd = await this.proveAddedSource(toolId, hostName, builtDir, projectRoot, warnings);
+ return {
+ hostName,
+ outcome: afterAdd === undefined ? "refused" : "registered",
+ claimable: afterAdd !== undefined,
+ addedThisRun: afterAdd !== undefined,
+ provenance: afterAdd,
+ };
+ }
+
+ private async proveAddedSource(
+ toolId: ToolId,
+ hostName: string,
+ builtDir: string,
+ projectRoot: string,
+ warnings: string[]
+ ): Promise {
+ if (!isAiToolId(toolId)) return undefined;
+ const reading = await this.nativeSources.get(toolId)?.read(projectRoot);
+ const current = reading?.entries?.get(hostName);
+ const expected = await this.fs.realpath(builtDir).catch(() => builtDir);
+ const sourceMatches =
+ current?.source === expected &&
+ (current.kind === "registry" ||
+ (current.kind === "effective-list" &&
+ current.sourceType === "local" &&
+ current.root === expected));
+ if (sourceMatches) return current;
+ const message = `${toolId}: catalogue '${hostName}' add returned but effective host source could not be proven as '${expected}'; no AIDD claim or enablement recorded. Reconcile manually.`;
+ this.logger.warn(message);
+ warnings.push(message);
+ return undefined;
}
/**
@@ -574,29 +1051,23 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
`Marketplace '${check.name}' is already registered from a different catalog: ` +
`${check.registeredSource} differs from the one requested, ${check.requestedSource} ` +
`— plugins ${describePluginDiff(diff)}, per ${check.location}. ` +
- `Run \`claude plugin marketplace remove ${check.name}\`, then \`aidd sync\` again ` +
- `to re-register it for this project.`
+ `Do not remove a catalogue another user or project may depend on. ` +
+ `Reconcile the host source manually before retrying \`aidd sync\`.`
);
}
/**
* A host already following a newer build is never written backward — warned, not thrown, so a
* caller iterating several tools still proceeds. A host tracking *another* project's
- * pre-migration cache is no refusal: the repoint completes that migration and its claim is
- * recorded alongside this project's, but only once that root is proven to still exist —
- * `resolveProjectRootForReferences` falls back to the path as given on `ENOENT`.
+ * pre-migration cache can proceed only through the separate host-source proof; discovering a
+ * path here never creates a shared-source claim before registration succeeds.
*/
private async decideOnDrift(
toolId: ToolId,
found: MarketplaceSourceDriftFound,
warnings: string[]
): Promise<"proceed" | "skip"> {
- if (found.drift.kind === "unmigrated-foreign-project-source") {
- if (await this.fs.fileExists(found.drift.projectRoot)) {
- await this.recordReferenceForRoot(found.drift.projectRoot);
- }
- return "proceed";
- }
+ if (found.drift.kind === "unmigrated-foreign-project-source") return "proceed";
if (found.drift.kind !== "version-behind") return "proceed";
const { registeredVersion, requestedVersion } = found.drift;
const message =
@@ -609,45 +1080,86 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
return "skip";
}
- // `add` refused, which for a global registry means the name is already held. A registration that
- // still resolves belongs to a live project and taking it would break that project; one whose
- // source is gone belongs to nobody. `hostName`, never `marketplace.name`, drives every host-facing
- // call below: the alias would answer "dead" for a live registration whenever the two differ. The
- // reserved framework name at `"user"` scope is reclaimed on any refusal, not only a proven-dead
- // one — codex answers `"unknown"` for every name, copilot `"live"` — safe only because every
- // registration under that name is this CLI's own packaged catalog.
- private reclaimOrReport(
+ // Reclaim only a dead Claude registration whose current source is recognisably AIDD-owned.
+ // `hostName`, never the project's alias, names every host-facing call.
+ private async reclaimOrReport(
toolId: ToolId,
activator: NativePluginActivator,
marketplace: Marketplace,
hostName: string,
builtDir: string,
+ projectRoot: string,
addError: NativePluginCliError,
warnings: string[]
- ): void {
- const state = activator.registrationState(hostName);
- const isUnguardedFrameworkMarketplace =
- marketplace.name === FRAMEWORK_MARKETPLACE_NAME &&
- marketplace.scope === "user" &&
- (!isAiToolId(toolId) || nativeActivationOf(toolId)?.marketplaceRegistry === undefined);
- if (state !== "dead" && !isUnguardedFrameworkMarketplace) {
+ ): Promise {
+ const proof = await this.nativeCatalogProof(toolId, hostName, projectRoot);
+ if (
+ !proof.canonical ||
+ !proof.hostReadable ||
+ proof.sourceStatus !== "owned" ||
+ proof.foreignRef !== undefined
+ ) {
+ const message =
+ `Native plugin activation — catalogue '${hostName}' collides with unproven AIDD ownership` +
+ `${proof.foreignRef === undefined ? "" : ` and foreign ref '${proof.foreignRef}'`}; host registration left untouched. ${addError.message}`;
+ this.logger.warn(message);
+ warnings.push(message);
+ return false;
+ }
+ const registeredSource = isAiToolId(toolId)
+ ? (await this.hostMarketplaceRegistries.get(toolId)?.read())?.entries?.get(hostName)
+ : undefined;
+ const source =
+ registeredSource === undefined
+ ? undefined
+ : await this.fs.realpath(registeredSource).catch(() => registeredSource);
+ const projectSource =
+ source === undefined
+ ? undefined
+ : parseBuiltMarketplaceDir(
+ await this.fs.realpath(projectRoot).catch(() => projectRoot),
+ source
+ );
+ const userRoot = this.userCacheRoot();
+ const userSource =
+ source === undefined || userRoot === ""
+ ? undefined
+ : parseUserBuiltMarketplaceDir(
+ await this.fs.realpath(userRoot).catch(() => userRoot),
+ source
+ );
+ const provenSource = [projectSource, userSource].some(
+ (location) =>
+ location !== undefined &&
+ samePathSegment(location.marketplaceName, marketplace.name) &&
+ samePathSegment(location.target, toolId)
+ );
+ if (!provenSource) {
+ const message =
+ `Native plugin activation — catalogue '${hostName}' host catalogue source is unproven; ` +
+ `host registration left untouched. ${addError.message}`;
+ this.logger.warn(message);
+ warnings.push(message);
+ return false;
+ }
+ if (activator.registrationState(hostName) !== "dead") {
const message = `Native plugin activation — register marketplace '${hostName}' skipped: ${addError.message}`;
this.logger.warn(message);
warnings.push(message);
- return;
+ return false;
}
- const reclaimMessage =
- state === "dead"
- ? `Marketplace '${hostName}' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back.`
- : `Marketplace '${hostName}' is registered from a different source and ${toolId} refuses to overwrite it in place; removing and re-registering it from the shared, machine-scope build. Plugins installed from it are removed and the ones this CLI manages are put back.`;
+ const reclaimMessage = `Marketplace '${hostName}' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back.`;
this.logger.warn(reclaimMessage);
warnings.push(reclaimMessage);
- this.bestEffort(
- () => activator.removeMarketplace(hostName, marketplace.scope, { force: true }),
- `unregister stale marketplace '${hostName}'`,
- warnings
- );
- this.bestEffort(
+ if (
+ !this.bestEffort(
+ () => activator.removeMarketplace(hostName, marketplace.scope, { force: true }),
+ `unregister stale marketplace '${hostName}'`,
+ warnings
+ )
+ )
+ return false;
+ return this.bestEffort(
() => activator.addMarketplace(builtDir, marketplace.scope),
`register marketplace '${hostName}'`,
warnings
@@ -759,11 +1271,13 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
projectRoot: string,
manifest: Manifest,
marketplaces: readonly Marketplace[],
- settings: MarketplaceSettings
+ settings: MarketplaceSettings,
+ provenRefs?: readonly string[]
): Promise {
const pluginsPath = resolve(projectRoot, settings.settingsPath);
const json = await this.loadSettings(pluginsPath);
- if (!this.mergeEnabledPlugins(json, settings, toolId, manifest, marketplaces)) return false;
+ if (!this.mergeEnabledPlugins(json, settings, toolId, manifest, marketplaces, provenRefs))
+ return false;
const content = JSON.stringify(json, null, 2);
await this.fs.writeFile(pluginsPath, content);
manifest.updateTrackedFileHash(toolId, settings.settingsPath, this.hasher.hash(content));
@@ -775,12 +1289,19 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings {
settings: MarketplaceSettings,
toolId: ToolId,
manifest: Manifest,
- marketplaces: readonly Marketplace[]
+ marketplaces: readonly Marketplace[],
+ provenRefs?: readonly string[]
): boolean {
const pluginsKey = settings.enabledPluginsKey;
if (pluginsKey == null) return false;
const existing = this.existingRecord(json, pluginsKey);
const toAdd: Record = {};
+ if (provenRefs !== undefined) {
+ for (const ref of provenRefs) if (!(ref in existing)) toAdd[ref] = true;
+ if (Object.keys(toAdd).length === 0) return false;
+ json[pluginsKey] = { ...existing, ...toAdd };
+ return true;
+ }
const marketplaceByName = new Map(marketplaces.map((m) => [m.name, m]));
for (const plugin of manifest.getPlugins(toolId)) {
if (plugin.marketplace == null) continue;
@@ -850,9 +1371,14 @@ function marketplaceRegistrationsEqual(
): boolean {
return (
a.length === b.length &&
- a.every(
- (value, index) => value.alias === b[index]?.alias && value.hostName === b[index]?.hostName
- )
+ a.every((value, index) => {
+ const other = b[index];
+ if (other === undefined || value.alias !== other.alias || value.hostName !== other.hostName)
+ return false;
+ if (value.provenance === undefined || other.provenance === undefined)
+ return value.provenance === other.provenance;
+ return sameNativeMarketplaceSource(value.provenance, other.provenance);
+ })
);
}
diff --git a/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts b/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts
index f55b4eb18..94a5eec9c 100644
--- a/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts
+++ b/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts
@@ -15,18 +15,23 @@ import type { AiToolId } from "../../../../../kernel/tool.js";
import type { MarketplaceRegistry } from "../../../../distribution/domain/ports/marketplace-registry.js";
import {
frameworkBuildModeFor,
+ getAiToolConfig,
resolvePluginsCapability,
} from "../../../../tools/domain/registry.js";
import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js";
import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js";
import type { Manifest } from "../../../domain/manifest.js";
-import { InstalledPlugin } from "../../../domain/plugins/installed-plugin.js";
+import {
+ InstalledPlugin,
+ type ProjectHooksProvenance,
+} from "../../../domain/plugins/installed-plugin.js";
import { isPluginFileAtDesiredState } from "../../plugin/plugin-helpers.js";
import {
resolveBaseDirFromRecord,
resolveScopeForInstall,
} from "../../plugin/plugin-target-resolution.js";
import type { EnsureBuiltMarketplace } from "../../shared/ensure-built-marketplace-use-case.js";
+import { materializeFlatMcp, refreshTrackedMcpConfigHash } from "./flat-mcp-materializer.js";
import { ModeBFlatMaterializationTranslator } from "./mode-b-flat-materialization-translator.js";
import type { PluginTranslator } from "./plugin-translator.js";
import { ProjectHooksMaterializer } from "./project-hooks-materializer.js";
@@ -51,7 +56,9 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
projectRoot: string,
manifest: Manifest,
marketplace: string | undefined,
- previousMcpEntries: ReadonlyMap = new Map()
+ previousMcpEntries: ReadonlyMap = new Map(),
+ userScopeDirTaken = false,
+ previousProjectHooks?: ProjectHooksProvenance
): Promise<{ skipped: ReadonlySkipList; written?: number }> {
const resolved =
marketplace === undefined ? null : await this.findMarketplace(marketplace, projectRoot);
@@ -63,7 +70,9 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
projectRoot,
manifest,
marketplace,
- previousMcpEntries
+ previousMcpEntries,
+ userScopeDirTaken,
+ previousProjectHooks
);
}
const mode = frameworkBuildModeFor(toolId);
@@ -76,31 +85,54 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
const builtFiles =
mode === "flat"
? await this.readFlatFiles(builtDir, dist, toolId)
- : await this.readBuiltFiles(
- join(builtDir, "plugins", dist.manifest.name),
- dist.manifest.name
- );
+ : await readBuiltUserPluginFiles(this.fs, this.hasher, builtDir, dist.manifest.name);
// The built tree still carries a plugin-scoped `hooks/hooks.json` for a capability declaring
// `hooksDestination: "project"` — dropped here and materialized through the same project-hooks
// side channel the local-source route uses, so both land where the tool's own declaration says.
const deliversHooksToProject = resolvePluginsCapability(toolId)?.hooksDestination === "project";
- const hooksSkips = deliversHooksToProject
- ? await this.projectHooks.materialize(dist, toolId, projectRoot)
- : [];
+ const hooks = deliversHooksToProject
+ ? await this.projectHooks.materializeWithProvenance(
+ dist,
+ toolId,
+ projectRoot,
+ previousProjectHooks
+ )
+ : { skipped: [] as ReadonlySkipList };
const files = deliversHooksToProject
? withoutHooksPrefix(builtFiles, dist.manifest.name)
: builtFiles;
+ const mcp = await materializeFlatMcp(
+ this.fs,
+ this.hasher,
+ dist,
+ toolId,
+ projectRoot,
+ previousMcpEntries
+ );
+ await refreshTrackedMcpConfigHash(this.fs, manifest, toolId, projectRoot, mcp.outputRelPath);
const scope = resolveScopeForInstall(toolId);
const baseDir =
mode === "flat"
? projectRoot
: resolveBaseDirFromRecord(scope, toolId, projectRoot, this.homedir);
- const written = await this.writeChangedFiles(files, baseDir);
+ const owned = userScopeDirTaken ? [] : files;
+ const written = await this.writeChangedFiles(owned, baseDir);
manifest.addPlugin(
toolId,
- InstalledPlugin.fromDistribution(dist, source, files, scope, new Map(), marketplace)
+ InstalledPlugin.withProjectHooks(
+ InstalledPlugin.fromDistributionWithMcp(
+ dist,
+ source,
+ owned,
+ mcp.mcpEntries,
+ scope,
+ new Map(),
+ marketplace
+ ),
+ hooks.projectHooks
+ )
);
- return { skipped: hooksSkips, written };
+ return { skipped: [...mcp.mcpSkips, ...hooks.skipped], written };
}
// Skips a file already matching the built content on disk, so a no-op restore reports (and
@@ -118,26 +150,6 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
return written;
}
- // Marketplace build emits plugins//; user-scope tools install at
- // //, so the manifest relativePath keeps the / prefix.
- private async readBuiltFiles(pluginSrc: string, name: string): Promise {
- const absPaths = await this.fs.listFilesRecursive(pluginSrc);
- return Promise.all(
- absPaths.map(async (abs) => {
- const rel = posixRelative(pluginSrc, abs);
- const content = await this.fs.readFile(abs);
- return new InstallationFile({
- // relativePath is always "/"-separated (see withoutHooksPrefix and
- // belongsToPlugin below, both string-matching on "/") - node:path's platform
- // `join` would answer with "\" on win32, breaking both.
- relativePath: posix.join(name, rel),
- content,
- hash: this.hasher.hash(content),
- });
- })
- );
- }
-
// Flat build emits the whole marketplace into one workspace. Agents are namespaced by
// `-`; skills instead nest the whole subtree under `skills//`, since a
// skill's own script can `require()` a sibling by relative path, which only keeps resolving while
@@ -155,7 +167,7 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
const files: InstallationFile[] = [];
for (const abs of absPaths) {
const rel = posixRelative(builtDir, abs);
- if (!this.belongsToPlugin(rel, name) && !hookPaths.has(rel)) continue;
+ if (!this.belongsToPlugin(rel, name, toolId) && !hookPaths.has(rel)) continue;
const content = await this.fs.readFile(abs);
files.push(
new InstallationFile({ relativePath: rel, content, hash: this.hasher.hash(content) })
@@ -164,9 +176,10 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
return files;
}
- private belongsToPlugin(rel: string, name: string): boolean {
+ private belongsToPlugin(rel: string, name: string, toolId: AiToolId): boolean {
const segments = rel.split("/");
- if (segments[0] !== ".opencode" || segments.length < 3) return false;
+ const toolDirectory = getAiToolConfig(toolId).directory.replace(/\/$/, "");
+ if (segments[0] !== toolDirectory || segments.length < 3) return false;
// `skills/` nests the whole plugin under one exactly-named segment; every other flat section
// hyphen-prefixes the leaf segment.
if (segments[1] === "skills") return segments[2] === name;
@@ -178,7 +191,7 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
const flatHooksDir = plugins?.flatHooksDir;
if (plugins === null || flatHooksDir === null || flatHooksDir === undefined) return new Set();
const name = dist.manifest.name;
- return new Set(
+ const paths = new Set(
dist.components.hooks
.filter((f) => f.relativePath !== "hooks/hooks.json")
.map((f) =>
@@ -190,6 +203,15 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
)
)
);
+ const bridge = plugins.flatHooksBridge;
+ const hooksJson = dist.components.hooks.find((f) => f.relativePath === "hooks/hooks.json");
+ const hasOwnBridge =
+ bridge !== null &&
+ dist.components.hooks.some((f) => f.relativePath.endsWith(`/${bridge.skipIfSourceHas}`));
+ if (bridge !== null && hooksJson !== undefined && !hasOwnBridge) {
+ if (bridge.generate(hooksJson.content, name) !== null) paths.add(bridge.path(name));
+ }
+ return paths;
}
private async findMarketplace(name: string, projectRoot: string) {
@@ -202,9 +224,33 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
}
}
+export async function readBuiltUserPluginFiles(
+ fs: FileReader,
+ hasher: Hasher,
+ builtDir: string,
+ name: string
+): Promise {
+ const pluginSrc = join(builtDir, "plugins", name);
+ const absPaths = await fs.listFilesRecursive(pluginSrc);
+ return Promise.all(
+ absPaths.map(async (abs) => {
+ const rel = posixRelative(pluginSrc, abs);
+ const content = await fs.readFile(abs);
+ return new InstallationFile({
+ relativePath: posix.join(name, rel),
+ content,
+ hash: hasher.hash(content),
+ });
+ })
+ );
+}
+
// `readBuiltFiles` prefixes every path with `/`, so a built-tree hooks file always reads
// `/hooks/`.
-function withoutHooksPrefix(files: InstallationFile[], pluginName: string): InstallationFile[] {
+export function withoutHooksPrefix(
+ files: InstallationFile[],
+ pluginName: string
+): InstallationFile[] {
const hooksPrefix = `${pluginName}/hooks/`;
return files.filter((f) => !f.relativePath.startsWith(hooksPrefix));
}
diff --git a/cli/src/contexts/framework/application/framework/translator/flat-mcp-materializer.ts b/cli/src/contexts/framework/application/framework/translator/flat-mcp-materializer.ts
new file mode 100644
index 000000000..afbcb0d54
--- /dev/null
+++ b/cli/src/contexts/framework/application/framework/translator/flat-mcp-materializer.ts
@@ -0,0 +1,89 @@
+import { join } from "node:path";
+import type { FileReader } from "../../../../../kernel/ports/file-reader.js";
+import type { FileWriter } from "../../../../../kernel/ports/file-writer.js";
+import type { Hasher } from "../../../../../kernel/ports/hasher.js";
+import type { AiToolId } from "../../../../../kernel/tool.js";
+import type { McpCapability } from "../../../../tools/domain/capabilities/mcp-capability.js";
+import { mergeOpencodeMcp } from "../../../../tools/domain/formats/opencode-mcp-merge.js";
+import { getToolConfig, isAiTool } from "../../../../tools/domain/registry.js";
+import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js";
+import type {
+ PluginTranslationSkip,
+ ReadonlySkipList,
+} from "../../../../translate/domain/plugin-translation-skip.js";
+import type { Manifest } from "../../../domain/manifest.js";
+import { isFrameworkPrimeFlatMcp } from "../../plugin/plugin-target-resolution.js";
+
+export async function refreshTrackedMcpConfigHash(
+ fs: FileReader,
+ manifest: Manifest,
+ toolId: AiToolId,
+ projectRoot: string,
+ outputRelPath: string | undefined
+): Promise {
+ if (outputRelPath === undefined || !manifest.isFileTracked(outputRelPath)) return;
+ const outputPath = join(projectRoot, outputRelPath);
+ if (await fs.fileExists(outputPath)) {
+ manifest.updateTrackedFileHash(toolId, outputRelPath, await fs.readFileHash(outputPath));
+ }
+}
+
+export async function materializeFlatMcp(
+ fs: FileReader & FileWriter,
+ hasher: Hasher,
+ dist: PluginDistribution,
+ toolId: AiToolId,
+ projectRoot: string,
+ previousMcpEntries: ReadonlyMap
+): Promise<{
+ mcpEntries: ReadonlyMap;
+ mcpSkips: ReadonlySkipList;
+ outputRelPath?: string;
+}> {
+ const toolConfig = getToolConfig(toolId);
+ if (!isAiTool(toolConfig) || dist.components.mcp.length === 0) {
+ return { mcpEntries: new Map(), mcpSkips: [], outputRelPath: undefined };
+ }
+ const caps = toolConfig.capabilities as Record;
+ if (!isFrameworkPrimeFlatMcp(caps)) {
+ return { mcpEntries: new Map(), mcpSkips: [], outputRelPath: undefined };
+ }
+
+ const mcpCap = caps.mcp as McpCapability;
+ const outputRelPath = await mcpCap.resolveOutput(projectRoot, fs);
+ const outputPath = join(projectRoot, outputRelPath);
+ const existingContent = await readExistingJson(fs, outputPath);
+ const transformed = mcpCap.transform(dist.components.mcp[0].content);
+ const { mergedContent, contributedEntries, collisions, editedPreviousEntries } = mergeOpencodeMcp(
+ existingContent,
+ transformed,
+ previousMcpEntries,
+ hasher
+ );
+ if (editedPreviousEntries.length > 0) {
+ throw new Error(
+ `MCP server '${editedPreviousEntries[0]}' was edited after install; reinstall refused.`
+ );
+ }
+ if (contributedEntries.size > 0 || previousMcpEntries.size > 0) {
+ await fs.writeFile(outputPath, mergedContent);
+ }
+ const mcpSkips = collisions.map(
+ (reason): PluginTranslationSkip => ({
+ pluginName: dist.manifest.name,
+ component: "mcp",
+ toolId,
+ reason,
+ })
+ );
+ return { mcpEntries: contributedEntries, mcpSkips, outputRelPath };
+}
+
+async function readExistingJson(fs: FileReader, path: string): Promise {
+ try {
+ return await fs.readFile(path);
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
+ throw err;
+ }
+}
diff --git a/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts b/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts
index c36e99af3..fbe575bc2 100644
--- a/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts
+++ b/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts
@@ -1,4 +1,3 @@
-import { join } from "node:path";
import { CursorProjectScopeUnsupportedError } from "../../../../../kernel/errors.js";
import type { InstallationFile } from "../../../../../kernel/file.js";
import type { FileReader } from "../../../../../kernel/ports/file-reader.js";
@@ -6,24 +5,23 @@ import type { FileWriter } from "../../../../../kernel/ports/file-writer.js";
import type { Hasher } from "../../../../../kernel/ports/hasher.js";
import type { PluginSource } from "../../../../../kernel/source.js";
import type { AiToolId } from "../../../../../kernel/tool.js";
-import type { McpCapability } from "../../../../tools/domain/capabilities/mcp-capability.js";
import type { PluginsCapability } from "../../../../tools/domain/capabilities/plugins-capability.js";
-import { mergeOpencodeMcp } from "../../../../tools/domain/formats/opencode-mcp-merge.js";
import { getToolConfig, isAiTool } from "../../../../tools/domain/registry.js";
import { PluginContentTranslator } from "../../../../translate/domain/content-translator.js";
import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js";
-import type {
- PluginTranslationSkip,
- ReadonlySkipList,
-} from "../../../../translate/domain/plugin-translation-skip.js";
+import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js";
import type { Manifest } from "../../../domain/manifest.js";
-import { InstalledPlugin, type PluginScope } from "../../../domain/plugins/installed-plugin.js";
+import {
+ InstalledPlugin,
+ type PluginScope,
+ type ProjectHooksProvenance,
+} from "../../../domain/plugins/installed-plugin.js";
import { writePluginFiles } from "../../plugin/plugin-helpers.js";
import {
- isFrameworkPrimeFlatMcp,
resolveBaseDirFromRecord,
resolveScopeForInstall,
} from "../../plugin/plugin-target-resolution.js";
+import { materializeFlatMcp, refreshTrackedMcpConfigHash } from "./flat-mcp-materializer.js";
import type { PluginTranslator } from "./plugin-translator.js";
import { ProjectHooksMaterializer, withoutHooks } from "./project-hooks-materializer.js";
@@ -51,20 +49,37 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator {
projectRoot: string,
manifest: Manifest,
marketplace: string | undefined,
- previousMcpEntries: ReadonlyMap = new Map()
+ previousMcpEntries: ReadonlyMap = new Map(),
+ userScopeDirTaken = false,
+ previousProjectHooks?: ProjectHooksProvenance
): Promise<{ skipped: ReadonlySkipList }> {
const ctx = this.resolveFlatToolContext(toolId, dist, projectRoot);
if (ctx === null) return { skipped: [] };
- const mcp = await this.resolveMcp(dist, toolId, projectRoot, previousMcpEntries);
- const hooksSkips = await this.projectHooks.materialize(dist, toolId, projectRoot);
- const allSkipped: ReadonlySkipList = [...ctx.skipped, ...mcp.mcpSkips, ...hooksSkips];
- if (ctx.files.length === 0 && mcp.mcpEntries.size === 0) return { skipped: allSkipped };
+ const mcp = await materializeFlatMcp(
+ this.fs,
+ this.hasher,
+ dist,
+ toolId,
+ projectRoot,
+ previousMcpEntries
+ );
+ await refreshTrackedMcpConfigHash(this.fs, manifest, toolId, projectRoot, mcp.outputRelPath);
+ const hooks = await this.projectHooks.materializeWithProvenance(
+ dist,
+ toolId,
+ projectRoot,
+ previousProjectHooks
+ );
+ const allSkipped: ReadonlySkipList = [...ctx.skipped, ...mcp.mcpSkips, ...hooks.skipped];
+ if (ctx.files.length === 0 && mcp.mcpEntries.size === 0 && hooks.projectHooks === undefined)
+ return { skipped: allSkipped };
await this.writeAndRegisterPlugin(
dist,
toolId,
source,
- ctx.files,
+ userScopeDirTaken ? [] : ctx.files,
mcp.mcpEntries,
+ hooks.projectHooks,
ctx.componentPaths,
marketplace,
ctx.baseDir,
@@ -102,27 +117,13 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator {
return { caps, files, componentPaths, skipped, baseDir, scope };
}
- private async resolveMcp(
- dist: PluginDistribution,
- toolId: AiToolId,
- projectRoot: string,
- previousMcpEntries: ReadonlyMap
- ): Promise<{ mcpEntries: ReadonlyMap; mcpSkips: ReadonlySkipList }> {
- const toolConfig = getToolConfig(toolId);
- if (!isAiTool(toolConfig)) return { mcpEntries: new Map(), mcpSkips: [] };
- const caps = toolConfig.capabilities as Record;
- if (!isFrameworkPrimeFlatMcp(caps) || dist.components.mcp.length === 0) {
- return { mcpEntries: new Map(), mcpSkips: [] };
- }
- return this.mergeOpencodeMcpEntries(dist, caps, projectRoot, previousMcpEntries, toolId);
- }
-
private async writeAndRegisterPlugin(
dist: PluginDistribution,
toolId: AiToolId,
source: PluginSource,
files: InstallationFile[],
mcpEntries: ReadonlyMap,
+ projectHooks: ProjectHooksProvenance | undefined,
componentPaths: ReadonlyMap,
marketplace: string | undefined,
baseDir: string,
@@ -139,56 +140,6 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator {
componentPaths,
marketplace
);
- manifest.addPlugin(toolId, plugin);
- }
-
- private async mergeOpencodeMcpEntries(
- dist: PluginDistribution,
- caps: Record,
- projectRoot: string,
- previousMcpEntries: ReadonlyMap,
- toolId: AiToolId
- ): Promise<{ mcpEntries: ReadonlyMap; mcpSkips: ReadonlySkipList }> {
- const mcpCap = caps.mcp as McpCapability;
- const outputRelPath = await mcpCap.resolveOutput(projectRoot, this.fs);
- const outputPath = join(projectRoot, outputRelPath);
- const existingContent = await this.readExistingJson(outputPath);
- const rawMcp = dist.components.mcp[0].content;
- const transformed = mcpCap.transform(rawMcp);
- const { mergedContent, contributedEntries, collisions } = mergeOpencodeMcp(
- existingContent,
- transformed,
- previousMcpEntries,
- this.hasher
- );
- if (contributedEntries.size > 0 || previousMcpEntries.size > 0) {
- await this.fs.writeFile(outputPath, mergedContent);
- }
- const mcpSkips = this.collisionsToSkips(collisions, dist.manifest.name, toolId);
- return { mcpEntries: contributedEntries, mcpSkips };
- }
-
- private collisionsToSkips(
- collisions: ReadonlyArray,
- pluginName: string,
- toolId: AiToolId
- ): ReadonlySkipList {
- return collisions.map(
- (reason): PluginTranslationSkip => ({
- pluginName,
- component: "mcp",
- toolId,
- reason,
- })
- );
- }
-
- private async readExistingJson(path: string): Promise {
- try {
- return await this.fs.readFile(path);
- } catch (err) {
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
- throw err;
- }
+ manifest.addPlugin(toolId, InstalledPlugin.withProjectHooks(plugin, projectHooks));
}
}
diff --git a/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts b/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts
index 6cb29bfcd..b287cb23e 100644
--- a/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts
+++ b/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts
@@ -4,6 +4,7 @@ import type { PluginTranslationMode } from "../../../../tools/domain/plugin-tran
import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js";
import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js";
import type { Manifest } from "../../../domain/manifest.js";
+import type { ProjectHooksProvenance } from "../../../domain/plugins/installed-plugin.js";
/** A translator strategy contract, not a hexagonal port adapter. */
export interface PluginTranslator {
@@ -23,6 +24,8 @@ export interface PluginTranslator {
projectRoot: string,
manifest: Manifest,
marketplace: string | undefined,
- previousMcpEntries?: ReadonlyMap
+ previousMcpEntries?: ReadonlyMap,
+ userScopeDirTaken?: boolean,
+ previousProjectHooks?: ProjectHooksProvenance
): Promise<{ skipped: ReadonlySkipList; written?: number }>;
}
diff --git a/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts b/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts
index 1a8735048..792d10837 100644
--- a/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts
+++ b/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts
@@ -1,4 +1,4 @@
-import { join } from "node:path";
+import { dirname, join } from "node:path";
import {
cursorProjectHooksScriptPath,
mergeCursorProjectHooksJson,
@@ -15,6 +15,12 @@ import type {
PluginTranslationSkip,
ReadonlySkipList,
} from "../../../../translate/domain/plugin-translation-skip.js";
+import type { ProjectHooksProvenance } from "../../../domain/plugins/installed-plugin.js";
+import {
+ assertProjectHooksUnchanged,
+ recordedProjectHookEntries,
+ removeRecordedProjectHooks,
+} from "../../shared/remove-project-hooks.js";
const HOOKS_MANIFEST_PATH = "hooks/hooks.json";
@@ -32,27 +38,53 @@ export class ProjectHooksMaterializer {
toolId: AiToolId,
projectRoot: string
): Promise {
+ return (await this.materializeWithProvenance(dist, toolId, projectRoot)).skipped;
+ }
+
+ async materializeWithProvenance(
+ dist: PluginDistribution,
+ toolId: AiToolId,
+ projectRoot: string,
+ previous?: ProjectHooksProvenance
+ ): Promise<{ skipped: ReadonlySkipList; projectHooks?: ProjectHooksProvenance }> {
const pluginsCap = resolvePluginsCapability(toolId);
- if (pluginsCap === null || pluginsCap.hooksDestination !== "project") return [];
+ if (pluginsCap === null || pluginsCap.hooksDestination !== "project") return { skipped: [] };
const projectHooksRelativePath = pluginsCap.projectHooksRelativePath;
- if (projectHooksRelativePath === null) return [];
+ if (projectHooksRelativePath === null) return { skipped: [] };
const manifestFile = dist.components.hooks.find((f) => f.relativePath === HOOKS_MANIFEST_PATH);
- if (manifestFile === undefined) return [];
- const warnings = await this.mergeProjectHooksJson(
+ if (manifestFile === undefined) {
+ if (previous !== undefined) {
+ await removeRecordedProjectHooks(
+ this.fs,
+ dist.manifest.name,
+ previous,
+ toolId,
+ projectRoot
+ );
+ }
+ return { skipped: [] };
+ }
+ await assertProjectHooksUnchanged(this.fs, dist.manifest.name, previous, toolId, projectRoot);
+ await this.assertScriptsNotUserOwned(dist, projectRoot, previous);
+ const { warnings, entries } = await this.mergeProjectHooksJson(
dist,
manifestFile,
projectRoot,
projectHooksRelativePath
);
- await this.writeProjectHooksScripts(dist, projectRoot);
- return warnings.map(
- (reason): PluginTranslationSkip => ({
- pluginName: dist.manifest.name,
- component: "hooks",
- toolId,
- reason,
- })
- );
+ const scripts = await this.writeProjectHooksScripts(dist, projectRoot);
+ await this.removeObsoleteScripts(previous, scripts, projectRoot);
+ return {
+ skipped: warnings.map(
+ (reason): PluginTranslationSkip => ({
+ pluginName: dist.manifest.name,
+ component: "hooks",
+ toolId,
+ reason,
+ })
+ ),
+ projectHooks: { entries, scripts },
+ };
}
private async mergeProjectHooksJson(
@@ -60,7 +92,7 @@ export class ProjectHooksMaterializer {
manifestFile: PluginComponentFile,
projectRoot: string,
projectHooksRelativePath: string
- ): Promise {
+ ): Promise<{ warnings: readonly string[]; entries: ProjectHooksProvenance["entries"] }> {
const destPath = join(projectRoot, projectHooksRelativePath);
const existing = await this.readExistingJson(destPath);
const { content, warnings } = mergeCursorProjectHooksJson(
@@ -69,17 +101,55 @@ export class ProjectHooksMaterializer {
dist.manifest.name
);
await this.fs.writeFile(destPath, content);
- return warnings;
+ return { warnings, entries: recordedProjectHookEntries(content, dist.manifest.name) };
+ }
+
+ private async assertScriptsNotUserOwned(
+ dist: PluginDistribution,
+ projectRoot: string,
+ previous?: ProjectHooksProvenance
+ ): Promise {
+ for (const file of dist.components.hooks) {
+ if (file.relativePath === HOOKS_MANIFEST_PATH) continue;
+ const relativePath = cursorProjectHooksScriptPath(dist.manifest.name, file.relativePath);
+ if (previous?.scripts.has(relativePath) === true) continue;
+ if (await this.fs.fileExists(join(projectRoot, relativePath))) {
+ throw new Error(`Cursor hook script '${relativePath}' is user-owned; install refused.`);
+ }
+ }
}
private async writeProjectHooksScripts(
dist: PluginDistribution,
projectRoot: string
- ): Promise {
+ ): Promise> {
+ const scripts = new Map();
for (const file of dist.components.hooks) {
if (file.relativePath === HOOKS_MANIFEST_PATH) continue;
const dest = cursorProjectHooksScriptPath(dist.manifest.name, file.relativePath);
await this.fs.writeFile(join(projectRoot, dest), file.content);
+ scripts.set(dest, (await this.fs.readFileHash(join(projectRoot, dest))).value);
+ }
+ return scripts;
+ }
+
+ private async removeObsoleteScripts(
+ previous: ProjectHooksProvenance | undefined,
+ current: ReadonlyMap,
+ projectRoot: string
+ ): Promise {
+ if (previous === undefined) return;
+ for (const [relativePath, digest] of previous.scripts) {
+ if (current.has(relativePath)) continue;
+ const path = join(projectRoot, relativePath);
+ if (!(await this.fs.fileExists(path))) continue;
+ if ((await this.fs.readFileHash(path)).value !== digest) {
+ throw new Error(
+ `Cursor hook script '${relativePath}' was edited during reinstall; removal refused.`
+ );
+ }
+ await this.fs.deleteFile(path);
+ await this.fs.deleteEmptyDirectories(dirname(path));
}
}
diff --git a/cli/src/contexts/framework/application/global/restore-all-use-case.ts b/cli/src/contexts/framework/application/global/restore-all-use-case.ts
index b64833e77..87f48d1a4 100644
--- a/cli/src/contexts/framework/application/global/restore-all-use-case.ts
+++ b/cli/src/contexts/framework/application/global/restore-all-use-case.ts
@@ -36,6 +36,16 @@ export class RestoreAllUseCase {
if (manifest === null) throw new NoManifestError();
const effectiveFiles = interactive ? await this.promptForFiles(projectRoot) : undefined;
+ if (effectiveFiles !== undefined && effectiveFiles.length === 0) {
+ return {
+ totalRestored: 0,
+ totalKept: 0,
+ pluginNamesRestored: [],
+ errors,
+ unrestorable: [],
+ nativeOnlyToolIds: [],
+ };
+ }
const version = this.resolveVersion(manifest);
const restoreResult = await this.runConfigRestore(
projectRoot,
@@ -73,12 +83,12 @@ export class RestoreAllUseCase {
.filter((d) => d.status === "modified" || d.status === "deleted")
.map((d) => d.relativePath)
);
- if (driftedFiles.length === 0) return [];
+ if (driftedFiles.length === 0) return undefined;
const selected = await this.prompter.checkbox(
"Select files to restore:",
driftedFiles.map((f) => ({ name: f, value: f }))
);
- return selected.length === 0 ? [] : selected;
+ return selected;
}
private async runConfigRestore(
diff --git a/cli/src/contexts/framework/application/install/install-config-use-case.ts b/cli/src/contexts/framework/application/install/install-config-use-case.ts
index da935fd19..4b40ccbc7 100644
--- a/cli/src/contexts/framework/application/install/install-config-use-case.ts
+++ b/cli/src/contexts/framework/application/install/install-config-use-case.ts
@@ -8,7 +8,7 @@ import type { Platform } from "../../../../runtime/platform/platform.js";
import { CONFIG_MCP, type ConfigRef } from "../../../tools/domain/capabilities/config-refs.js";
import { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js";
import { SettingsCapability } from "../../../tools/domain/capabilities/settings-capability.js";
-import { transformFor as transformMcpForPlatform } from "../../../tools/domain/mcp-exclusion.js";
+import { transformFor as transformMcpForPlatform } from "../../../tools/domain/mcp-launch-command.js";
import type { ConfigCapability } from "../../domain/config-capability.js";
interface InstallConfigOptions {
diff --git a/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts b/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts
index d67612fdf..b866fb0e4 100644
--- a/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts
+++ b/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts
@@ -7,7 +7,9 @@ import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
import type { Hasher } from "../../../../kernel/ports/hasher.js";
import type { Logger } from "../../../../kernel/ports/logger.js";
import type { AiToolId } from "../../../../kernel/tool.js";
+import { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js";
import { SettingsCapability } from "../../../tools/domain/capabilities/settings-capability.js";
+import { buildOpencodeFlatConfig } from "../../../tools/domain/formats/opencode-mcp-merge.js";
import type { FileMerger } from "../../../tools/domain/ports/file-merger.js";
import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js";
import type { Manifest } from "../../domain/manifest.js";
@@ -78,16 +80,58 @@ export class InstallRuntimeConfigUseCase {
if (!isAiTool(toolConfig) || !toolConfig.configOutputPaths) return [];
const files: InstallationFile[] = [];
for (const [fileName, outputPath] of Object.entries(toolConfig.configOutputPaths)) {
+ const resolvedPath = await this.resolveConfigPath(toolConfig, fileName, outputPath, options);
const asset = this.assets.loadConfigAsset(options.toolId, fileName);
- const content = typeof asset === "string" ? asset : JSON.stringify(asset, null, 2);
- if (await this.isUserOwned(outputPath, options)) continue;
+ let content = typeof asset === "string" ? asset : JSON.stringify(asset, null, 2);
+ if (await this.isUserOwned(resolvedPath, options)) continue;
+ const mcp = (toolConfig.capabilities as Record).mcp;
+ if (
+ mcp instanceof McpCapability &&
+ mcp.params.outputPath === fileName &&
+ mcp.params.format === "json" &&
+ mcp.params.mergeStrategy === "framework-prime"
+ ) {
+ content = buildOpencodeFlatConfig(
+ content,
+ await this.readExistingConfig(resolvedPath, options.projectRoot),
+ {}
+ );
+ }
files.push(
- new InstallationFile({ relativePath: outputPath, content, hash: this.hasher.hash(content) })
+ new InstallationFile({
+ relativePath: resolvedPath,
+ content,
+ hash: this.hasher.hash(content),
+ })
);
}
return files;
}
+ private async resolveConfigPath(
+ toolConfig: Extract, { kind: "ai" }>,
+ fileName: string,
+ outputPath: string,
+ options: InstallRuntimeConfigOptions
+ ): Promise {
+ const caps = toolConfig.capabilities as Record;
+ const mcp = caps.mcp;
+ if (!(mcp instanceof McpCapability) || mcp.params.outputPath !== fileName) return outputPath;
+ return mcp.resolveOutput(options.projectRoot, this.fs);
+ }
+
+ private async readExistingConfig(
+ relativePath: string,
+ projectRoot: string
+ ): Promise {
+ try {
+ return await this.fs.readFile(join(projectRoot, relativePath));
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
+ throw error;
+ }
+ }
+
private buildStaticSettingsFiles(options: InstallRuntimeConfigOptions): InstallationFile[] {
const toolConfig = getToolConfig(options.toolId);
if (!isAiTool(toolConfig)) return [];
diff --git a/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts b/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts
new file mode 100644
index 000000000..5c36dce71
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts
@@ -0,0 +1,115 @@
+import type { AiToolId } from "../../../../kernel/tool.js";
+import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js";
+import type { NativeMarketplaceSourceReader } from "../../../tools/domain/ports/native-marketplace-source-reader.js";
+import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js";
+import { nativeActivationOf } from "../../../tools/domain/registry.js";
+import type {
+ NativeMarketplaceRegistration,
+ NativePluginClaim,
+ NativeRegistrations,
+} from "../../domain/manifest/native-registrations.js";
+import {
+ assertNoForeignNativeRefs,
+ inspectNativeMarketplaceSource,
+} from "./native-marketplace-source-proof.js";
+
+export class NativeHostRegistrationGate {
+ constructor(
+ private readonly activators: ReadonlyMap,
+ private readonly registries: ReadonlyMap,
+ private readonly sources: ReadonlyMap = new Map()
+ ) {}
+
+ async requireTargetedUpdate(
+ toolId: AiToolId,
+ claim: NativePluginClaim,
+ projectRoot: string,
+ registrations?: NativeRegistrations
+ ): Promise {
+ const activation = nativeActivationOf(toolId);
+ if (activation?.updateVerb === undefined) {
+ throw new Error(
+ `${toolId} does not support targeted native plugin update for '${claim.ref}'; use marketplace refresh or framework update instead.`
+ );
+ }
+ const activator = this.activators.get(activation.binary);
+ const reader = this.registries.get(toolId);
+ if (
+ activator === undefined ||
+ !activator.isAvailable() ||
+ activator.updatePlugin === undefined ||
+ reader === undefined
+ ) {
+ throw new Error(
+ `${toolId}: cannot update AIDD-owned ref '${claim.ref}' without its CLI and readable host registry; canonical claims retained.`
+ );
+ }
+ if ((await reader.read(projectRoot)).refs?.get(claim.ref)?.enabled !== true) {
+ throw new Error(
+ `${toolId}: ref '${claim.ref}' is not provably enabled on the host; update refused.`
+ );
+ }
+ const registration = registrations?.marketplaces.find((marketplace) =>
+ claim.ref.endsWith(`@${marketplace.hostName}`)
+ );
+ if (registration === undefined)
+ throw new Error(
+ `${toolId}: ref '${claim.ref}' has no canonical catalogue source proof; update refused.`
+ );
+ const proof = await inspectNativeMarketplaceSource(
+ this.sources.get(toolId),
+ projectRoot,
+ registration
+ );
+ if (proof.status !== "owned")
+ throw new Error(proof.reason ?? `${toolId}: catalogue source unproven.`);
+ return activator;
+ }
+
+ async planMarketplaceRemoval(
+ toolId: AiToolId,
+ registrations: NativeRegistrations,
+ registration: NativeMarketplaceRegistration,
+ projectRoot: string
+ ): Promise<{
+ activator: NativePluginActivator;
+ refs: readonly NativePluginClaim[];
+ scopes: ReadonlyMap;
+ }> {
+ const activator = this.activators.get(registrations.binary);
+ const reader = this.registries.get(toolId);
+ if (activator === undefined || !activator.isAvailable() || reader === undefined) {
+ throw new Error(
+ `Cannot prove/remove AIDD-owned catalogue '${registration.hostName}': ${registrations.binary} CLI or host registry unavailable.`
+ );
+ }
+ const proof = await inspectNativeMarketplaceSource(
+ this.sources.get(toolId),
+ projectRoot,
+ registration
+ );
+ if (proof.status !== "owned")
+ throw new Error(proof.reason ?? `${toolId}: catalogue source unproven.`);
+ const owned = new Set((registrations.pluginClaims ?? []).map((claim) => claim.ref));
+ const hostRefs = await assertNoForeignNativeRefs(
+ reader,
+ registration.hostName,
+ owned,
+ projectRoot
+ );
+ const refs = (registrations.pluginClaims ?? []).filter((claim) =>
+ claim.ref.endsWith(`@${registration.hostName}`)
+ );
+ for (const claim of refs) {
+ if (hostRefs.get(claim.ref)?.enabled !== true)
+ throw new Error(
+ `Cannot prove AIDD-owned host ref '${claim.ref}' is still enabled; refusing user-scope removal.`
+ );
+ }
+ return {
+ activator,
+ refs,
+ scopes: new Map([...hostRefs].map(([ref, state]) => [ref, state.scope])),
+ };
+ }
+}
diff --git a/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts b/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts
new file mode 100644
index 000000000..126784df5
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts
@@ -0,0 +1,110 @@
+import type {
+ HostPluginRegistryEntry,
+ HostPluginRegistryReader,
+ HostPluginRegistryReading,
+} from "../../../tools/domain/ports/host-plugin-registry-reader.js";
+import type {
+ NativeMarketplaceSource,
+ NativeMarketplaceSourceReader,
+ NativeMarketplaceSourceReading,
+} from "../../../tools/domain/ports/native-marketplace-source-reader.js";
+import type { NativeMarketplaceRegistration } from "../../domain/manifest/native-registrations.js";
+
+export interface NativeMarketplaceSourceInspection {
+ readonly status: "absent" | "owned" | "unproven";
+ readonly current?: NativeMarketplaceSource;
+ readonly reason?: string;
+}
+
+export function sameNativeMarketplaceSource(
+ recorded: NativeMarketplaceSource,
+ current: NativeMarketplaceSource
+): boolean {
+ if (recorded.kind !== current.kind || recorded.source !== current.source) return false;
+ if (recorded.kind === "registry" && current.kind === "registry") return true;
+ return (
+ recorded.kind === "effective-list" &&
+ current.kind === "effective-list" &&
+ recorded.root === current.root &&
+ recorded.sourceType === current.sourceType
+ );
+}
+
+export async function inspectNativeMarketplaceSource(
+ reader: NativeMarketplaceSourceReader | undefined,
+ projectRoot: string,
+ registration: NativeMarketplaceRegistration
+): Promise {
+ const name = registration.hostName;
+ if (reader === undefined)
+ return {
+ status: "unproven",
+ reason: `Catalogue '${name}': host source reader unavailable; reconcile manually.`,
+ };
+ let reading: NativeMarketplaceSourceReading;
+ try {
+ reading = await reader.read(projectRoot);
+ } catch (error) {
+ return {
+ status: "unproven",
+ reason: `Catalogue '${name}': host source read failed (${error instanceof Error ? error.message : String(error)}); reconcile manually.`,
+ };
+ }
+ if (reading.entries === undefined)
+ return {
+ status: "unproven",
+ reason: `Catalogue '${name}': ${reading.unreadable ?? `host source unreadable at ${reading.location}`}; reconcile manually.`,
+ };
+ if (!reading.entries.has(name)) return { status: "absent" };
+ const current = reading.entries.get(name);
+ if (current === undefined || current === null)
+ return {
+ status: "unproven",
+ reason: `Catalogue '${name}': current host source is unproven; reconcile manually.`,
+ };
+ if (registration.provenance === undefined)
+ return {
+ status: "unproven",
+ reason: `Catalogue '${name}': legacy claim has no source proof; reconcile manually.`,
+ };
+ if (!sameNativeMarketplaceSource(registration.provenance, current))
+ return {
+ status: "unproven",
+ reason: `Catalogue '${name}': current host source differs from AIDD's recorded source; reconcile manually.`,
+ };
+ return { status: "owned", current };
+}
+
+/** A source may be AIDD-owned while the host still carries somebody else's plugin from it. */
+export async function assertNoForeignNativeRefs(
+ reader: HostPluginRegistryReader | undefined,
+ hostName: string,
+ ownedRefs: ReadonlySet,
+ projectRoot: string
+): Promise> {
+ if (reader === undefined)
+ throw new Error(
+ `Catalogue '${hostName}': host plugin registry reader unavailable; no host mutation made.`
+ );
+ let reading: HostPluginRegistryReading;
+ try {
+ reading = await reader.read(projectRoot);
+ } catch (error) {
+ throw new Error(
+ `Catalogue '${hostName}': host plugin registry read failed (${error instanceof Error ? error.message : String(error)}); no host mutation made.`
+ );
+ }
+ if (reading.refs === undefined && reading.absent !== true)
+ throw new Error(
+ `Catalogue '${hostName}': host plugin registry unreadable (${reading.unreadable ?? reading.location}); no host mutation made.`
+ );
+ const refs = reading.refs ?? new Map();
+ const foreign = [...refs.keys()].find(
+ (ref) => ref.endsWith(`@${hostName}`) && !ownedRefs.has(ref)
+ );
+ if (foreign !== undefined)
+ throw new Error(
+ `Catalogue '${hostName}' includes foreign host ref '${foreign}'; no host mutation made.`
+ );
+ return refs;
+}
diff --git a/cli/src/contexts/framework/application/ownership/native-plugin-ownership.ts b/cli/src/contexts/framework/application/ownership/native-plugin-ownership.ts
new file mode 100644
index 000000000..b3fc392e8
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/native-plugin-ownership.ts
@@ -0,0 +1,55 @@
+import type { FileReader } from "../../../../kernel/ports/file-reader.js";
+import type { ToolId } from "../../../../kernel/tool.js";
+import type { NativePluginClaim } from "../../domain/manifest/native-registrations.js";
+import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
+
+export async function machineNativePluginClaim(
+ repo: ManifestRepository | undefined,
+ toolId: ToolId,
+ ref: string
+): Promise {
+ if (repo === undefined) return undefined;
+ const machine = await repo.load();
+ return machine?.getNativeRegistrations(toolId)?.pluginClaims?.find((claim) => claim.ref === ref);
+}
+
+export async function isMachineOwnedNativeRef(
+ repo: ManifestRepository | undefined,
+ toolId: ToolId,
+ ref: string
+): Promise {
+ return (await machineNativePluginClaim(repo, toolId, ref)) !== undefined;
+}
+
+export async function detachNativePluginRefs(
+ repo: ManifestRepository | undefined,
+ fs: FileReader,
+ projectRoot: string,
+ refsByTool: ReadonlyMap
+): Promise {
+ if (repo === undefined || refsByTool.size === 0) return;
+ const action = async () => {
+ const machine = await repo.load();
+ if (machine === null) return;
+ const root = await fs.realpath(projectRoot);
+ let changed = false;
+ for (const [toolId, refs] of refsByTool) {
+ const registration = machine.getNativeRegistrations(toolId);
+ if (registration?.pluginClaims === undefined) continue;
+ const targeted = new Set(refs);
+ const claims = registration.pluginClaims.map((claim) => {
+ if (!targeted.has(claim.ref) || !claim.dependents.includes(root)) return claim;
+ changed = true;
+ return {
+ ref: claim.ref,
+ dependents: claim.dependents.filter((dependent) => dependent !== root),
+ };
+ });
+ if (changed)
+ machine.setNativeRegistrations(toolId, { ...registration, pluginClaims: claims });
+ }
+ if (changed) await repo.save(machine);
+ };
+ if (repo.withExclusiveAccess !== undefined) await repo.withExclusiveAccess(action);
+ else await action();
+}
diff --git a/cli/src/contexts/framework/application/ownership/project-path-boundary.ts b/cli/src/contexts/framework/application/ownership/project-path-boundary.ts
new file mode 100644
index 000000000..71b04503c
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/project-path-boundary.ts
@@ -0,0 +1,43 @@
+import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
+import type { FileReader } from "../../../../kernel/ports/file-reader.js";
+
+/** Resolve an existing file or its nearest existing parent before mutating project-local content. */
+export async function assertProjectPathWithinRoot(
+ fs: FileReader,
+ projectRoot: string,
+ path: string
+): Promise {
+ const lexicalRoot = resolve(projectRoot);
+ const lexicalPath = resolve(path);
+ if (!strictlyWithin(lexicalRoot, lexicalPath)) {
+ throw new Error(`'${path}' escapes project root '${projectRoot}'; local mutation refused.`);
+ }
+ const canonicalRoot = await fs.realpath(lexicalRoot);
+ let candidate = lexicalPath;
+ while (true) {
+ try {
+ const canonical = await fs.realpath(candidate);
+ if (!within(canonicalRoot, canonical)) {
+ throw new Error(
+ `'${path}' resolves outside project root '${projectRoot}'; local mutation refused.`
+ );
+ }
+ return;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
+ if (candidate === lexicalRoot) {
+ throw new Error(`Project root '${projectRoot}' disappeared; local mutation refused.`);
+ }
+ candidate = dirname(candidate);
+ }
+ }
+}
+
+function within(root: string, path: string): boolean {
+ const rel = relative(root, path);
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
+}
+
+function strictlyWithin(root: string, path: string): boolean {
+ return root !== path && within(root, path);
+}
diff --git a/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts b/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts
new file mode 100644
index 000000000..d2bb2b54c
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts
@@ -0,0 +1,98 @@
+import { join } from "node:path";
+import type { FileReader } from "../../../../kernel/ports/file-reader.js";
+import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
+import type { AiToolId, ToolId } from "../../../../kernel/tool.js";
+import type { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js";
+import { unmergeOpencodeMcp } from "../../../tools/domain/formats/opencode-mcp-merge.js";
+import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js";
+import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js";
+import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
+import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js";
+import { isFrameworkPrimeFlatMcp } from "../plugin/plugin-target-resolution.js";
+import { assertProjectHooksRemovable, removeProjectHooks } from "../shared/remove-project-hooks.js";
+import { detachNativePluginRefs } from "./native-plugin-ownership.js";
+import { assertProjectPathWithinRoot } from "./project-path-boundary.js";
+import { detachUserPlugin } from "./user-plugin-ownership.js";
+
+/** Read-only OpenCode MCP proof, shared by Clean and local plugin removals before any detach. */
+export async function assertProjectMcpEntriesRemovable(
+ fs: FileReader,
+ plugin: InstalledPlugin,
+ toolId: AiToolId,
+ projectRoot: string
+): Promise {
+ await planProjectMcpRemoval(fs, plugin, toolId, projectRoot);
+}
+
+async function planProjectMcpRemoval(
+ fs: FileReader,
+ plugin: InstalledPlugin,
+ toolId: AiToolId,
+ projectRoot: string
+): Promise<{ output: string; content: string } | undefined> {
+ if (plugin.mcpEntries.size === 0) return undefined;
+ const config = getToolConfig(toolId);
+ if (!isAiTool(config)) return undefined;
+ const caps = config.capabilities as Record;
+ if (!isFrameworkPrimeFlatMcp(caps)) return undefined;
+ const mcp = caps.mcp as McpCapability;
+ const output = join(projectRoot, await mcp.resolveOutput(projectRoot, fs));
+ await assertProjectPathWithinRoot(fs, projectRoot, output);
+ try {
+ const existing = await fs.readFile(output);
+ return { output, content: unmergeOpencodeMcp(existing, plugin.mcpEntries) };
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
+ throw error;
+ }
+}
+
+export class ProjectPluginCleanup {
+ constructor(
+ private readonly fs: FileReader & FileWriter,
+ private readonly userManifestRepo?: ManifestRepository
+ ) {}
+
+ async assertLocalIntegrationRemovable(
+ toolId: AiToolId,
+ plugin: InstalledPlugin,
+ projectRoot: string
+ ): Promise {
+ await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, projectRoot);
+ await assertProjectHooksRemovable(this.fs, plugin, toolId, projectRoot);
+ }
+
+ async removeLocalIntegration(
+ toolId: AiToolId,
+ plugin: InstalledPlugin,
+ projectRoot: string
+ ): Promise {
+ const mcpUpdate = await planProjectMcpRemoval(this.fs, plugin, toolId, projectRoot);
+ await removeProjectHooks(this.fs, plugin, toolId, projectRoot);
+ if (mcpUpdate !== undefined) {
+ await assertProjectPathWithinRoot(this.fs, projectRoot, mcpUpdate.output);
+ await this.fs.writeFile(mcpUpdate.output, mcpUpdate.content);
+ }
+ }
+
+ async deleteLocalFiles(
+ toolId: AiToolId,
+ plugin: InstalledPlugin,
+ projectRoot: string
+ ): Promise {
+ await deletePluginFilesForTool(plugin.files, plugin.scope, toolId, projectRoot, this.fs);
+ }
+
+ async detachClaims(
+ projectRoot: string,
+ nativeRefs: ReadonlyMap,
+ plugins: readonly { toolId: AiToolId; plugin: InstalledPlugin }[]
+ ): Promise {
+ await detachNativePluginRefs(this.userManifestRepo, this.fs, projectRoot, nativeRefs);
+ for (const { toolId, plugin } of plugins) {
+ if (plugin.scope === "user") {
+ await detachUserPlugin(this.userManifestRepo, this.fs, toolId, plugin.name, projectRoot);
+ }
+ }
+ }
+}
diff --git a/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts b/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts
new file mode 100644
index 000000000..dfe15769b
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts
@@ -0,0 +1,156 @@
+import {
+ ActiveMachineDependentsError,
+ InvalidMarketplaceNameError,
+ MarketplaceNotFoundError,
+} from "../../../../kernel/errors.js";
+import type { FileReader } from "../../../../kernel/ports/file-reader.js";
+import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
+import { resolveHomeDir } from "../../../../kernel/reading/home-dir.js";
+import { AI_TOOL_IDS } from "../../../../kernel/tool.js";
+import {
+ FRAMEWORK_MARKETPLACE_NAME,
+ type Marketplace,
+} from "../../../distribution/domain/marketplace.js";
+import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js";
+import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
+import type {
+ MarketplaceRemoveOptions,
+ MarketplaceRemoveResult,
+} from "../flows/marketplace-remove-use-case.js";
+import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js";
+import { userScopeFilesSafeToDelete } from "../shared/user-scope-plugin-files.js";
+import type { NativeHostRegistrationGate } from "./native-host-registration-gate.js";
+
+export class UserMarketplaceRemoveUseCase {
+ constructor(
+ private readonly fs: FileReader & FileWriter,
+ private readonly repo: ManifestRepository,
+ private readonly registry: MarketplaceRegistry,
+ private readonly nativeHost: NativeHostRegistrationGate
+ ) {}
+
+ async execute(options: MarketplaceRemoveOptions): Promise {
+ if (options.name === FRAMEWORK_MARKETPLACE_NAME) {
+ throw new InvalidMarketplaceNameError(
+ `"${FRAMEWORK_MARKETPLACE_NAME}" is shared by every project on this machine and is removed by \`aidd clean --scope user\`, not marketplace remove.`
+ );
+ }
+ const marketplace = await this.findOrThrow(options.projectRoot, options.name);
+ const run = () => this.removeLocked(options, marketplace);
+ return this.repo.withExclusiveAccess === undefined ? run() : this.repo.withExclusiveAccess(run);
+ }
+
+ private async findOrThrow(projectRoot: string, name: string): Promise {
+ const found = (await this.registry.list(projectRoot)).find(
+ (entry) => entry.name === name && entry.scope === "user"
+ );
+ if (found === undefined) throw new MarketplaceNotFoundError(name);
+ return found;
+ }
+
+ private async removeLocked(
+ options: MarketplaceRemoveOptions,
+ marketplace: Marketplace
+ ): Promise {
+ const current = await this.findOrThrow(options.projectRoot, marketplace.name);
+ if (JSON.stringify(current.source) !== JSON.stringify(marketplace.source)) {
+ throw new Error(
+ `User-scope marketplace '${marketplace.name}' changed while waiting for the machine lock; retry against its current source.`
+ );
+ }
+ const manifest = await this.repo.load();
+ if (manifest === null)
+ throw new Error(
+ `Cannot prove AIDD ownership of user-scope marketplace '${marketplace.name}': no user manifest.`
+ );
+ const plugins = AI_TOOL_IDS.flatMap((toolId) =>
+ manifest
+ .getPlugins(toolId)
+ .filter((plugin) => plugin.scope === "user" && plugin.marketplace === marketplace.name)
+ .map((plugin) => ({ toolId, plugin }))
+ );
+ const native = AI_TOOL_IDS.flatMap((toolId) => {
+ const registrations = manifest.getNativeRegistrations(toolId);
+ if (registrations === undefined) return [];
+ return registrations.marketplaces
+ .filter((registration) => registration.alias === marketplace.name)
+ .map((registration) => ({ toolId, registrations, registration }));
+ });
+ if (plugins.length === 0 && native.length === 0) {
+ throw new Error(
+ `Cannot prove AIDD ownership of user-scope marketplace '${marketplace.name}': no canonical plugin or host catalogue claim.`
+ );
+ }
+ const nativeRefs = native.flatMap(({ registrations, registration }) =>
+ (registrations.pluginClaims ?? []).filter((claim) =>
+ claim.ref.endsWith(`@${registration.hostName}`)
+ )
+ );
+ const dependents = [
+ ...new Set([
+ ...plugins.flatMap(({ plugin }) => plugin.dependents),
+ ...nativeRefs.flatMap((claim) => claim.dependents),
+ ]),
+ ];
+ if (dependents.length > 0)
+ throw new ActiveMachineDependentsError(
+ `user-scope marketplace '${marketplace.name}'`,
+ dependents
+ );
+ const safeFiles = await Promise.all(
+ plugins.map(async ({ toolId, plugin }) => {
+ const files = await userScopeFilesSafeToDelete(
+ this.fs,
+ { debug() {}, info() {}, warn() {} },
+ plugin,
+ toolId,
+ resolveHomeDir()
+ );
+ if (files.size !== plugin.files.size)
+ throw new Error(
+ `Refusing partial user-scope marketplace removal: '${plugin.name}' has a tracked file outside its boundary.`
+ );
+ return { toolId, plugin, files };
+ })
+ );
+ const nativeCalls = await Promise.all(
+ native.map(async ({ toolId, registrations, registration }) => ({
+ toolId,
+ registrations,
+ registration,
+ ...(await this.nativeHost.planMarketplaceRemoval(
+ toolId,
+ registrations,
+ registration,
+ options.projectRoot
+ )),
+ }))
+ );
+ for (const { registration, activator, refs, scopes } of nativeCalls) {
+ for (const claim of refs)
+ activator.uninstallPlugin(claim.ref, scopes.get(claim.ref) ?? "user");
+ activator.removeMarketplace(registration.hostName, "user");
+ }
+ for (const { toolId, plugin, files } of safeFiles) {
+ await deletePluginFilesForTool(files, "user", toolId, options.projectRoot, this.fs, "user");
+ manifest.removePlugin(toolId, plugin.name);
+ }
+ for (const { toolId, registrations, registration } of nativeCalls) {
+ manifest.setNativeRegistrations(toolId, {
+ ...registrations,
+ marketplaces: registrations.marketplaces.filter(
+ (entry) => entry.alias !== registration.alias
+ ),
+ pluginRefs: registrations.pluginRefs.filter(
+ (ref) => !ref.endsWith(`@${registration.hostName}`)
+ ),
+ pluginClaims: (registrations.pluginClaims ?? []).filter(
+ (claim) => !claim.ref.endsWith(`@${registration.hostName}`)
+ ),
+ });
+ }
+ await this.registry.delete(options.projectRoot, marketplace.name, "user");
+ await this.repo.save(manifest);
+ return { marketplace, removedPluginCount: plugins.length + nativeRefs.length, orphanCount: 0 };
+ }
+}
diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts b/cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts
new file mode 100644
index 000000000..03fb25508
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts
@@ -0,0 +1,22 @@
+import { join } from "node:path";
+import { PLUGIN_CACHE_SUBDIR } from "../../../../kernel/paths.js";
+import type { PluginFetcher } from "../../../distribution/domain/ports/plugin-fetcher.js";
+import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js";
+import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js";
+import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js";
+
+export class UserPluginDistributionLoader {
+ constructor(
+ private readonly fetcher: PluginFetcher,
+ private readonly reader: PluginDistributionReader
+ ) {}
+
+ async loadLatest(plugin: InstalledPlugin, projectRoot: string): Promise {
+ const localPath = await this.fetcher.fetch(
+ plugin.source,
+ join(projectRoot, PLUGIN_CACHE_SUBDIR),
+ { forceRefresh: true }
+ );
+ return this.reader.read(localPath);
+ }
+}
diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts
new file mode 100644
index 000000000..3d4c99d4c
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts
@@ -0,0 +1,184 @@
+import { homedir as nodeHomedir } from "node:os";
+import { join } from "node:path";
+import type { InstallationFile } from "../../../../kernel/file.js";
+import type { FileReader } from "../../../../kernel/ports/file-reader.js";
+import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
+import type { Hasher } from "../../../../kernel/ports/hasher.js";
+import type { Logger } from "../../../../kernel/ports/logger.js";
+import { compareSemver } from "../../../../kernel/semver.js";
+import type { AiToolId } from "../../../../kernel/tool.js";
+import {
+ frameworkBuildModeFor,
+ getToolConfig,
+ resolvePluginsCapability,
+} from "../../../tools/domain/registry.js";
+import { PluginContentTranslator } from "../../../translate/domain/content-translator.js";
+import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js";
+import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js";
+import {
+ readBuiltUserPluginFiles,
+ withoutHooksPrefix,
+} from "../framework/translator/built-tree-materialization-translator.js";
+import { withoutHooks } from "../framework/translator/project-hooks-materializer.js";
+import { deleteOldFiles, writePluginFiles } from "../plugin/plugin-helpers.js";
+import { resolveBaseDirFromRecord } from "../plugin/plugin-target-resolution.js";
+import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use-case.js";
+import {
+ assertUserScopeWriteBoundary,
+ userScopeFilesSafeToDelete,
+} from "../shared/user-scope-plugin-files.js";
+import type { UserPluginDistributionLoader } from "./user-plugin-distribution-loader.js";
+
+export interface PlannedUserPluginFileUpdate {
+ readonly plugin: InstalledPlugin;
+ readonly toolId: AiToolId;
+ readonly home: string;
+ readonly baseDir: string;
+ readonly files: readonly InstallationFile[];
+ readonly oldSafe: ReadonlyMap;
+ readonly next: InstalledPlugin;
+}
+
+export class UserPluginFileUpdater {
+ constructor(
+ private readonly fs: FileReader & FileWriter,
+ private readonly loader: UserPluginDistributionLoader,
+ private readonly hasher: Hasher,
+ private readonly builtDeps?: BuiltMaterializationDeps
+ ) {}
+
+ async update(
+ plugin: InstalledPlugin,
+ toolId: AiToolId,
+ projectRoot: string,
+ logger: Logger
+ ): Promise {
+ const plan = await this.planUpdate(plugin, toolId, projectRoot, logger);
+ return plan === null ? null : this.applyUpdate(plan, logger);
+ }
+
+ async planUpdate(
+ plugin: InstalledPlugin,
+ toolId: AiToolId,
+ projectRoot: string,
+ logger: Logger
+ ): Promise {
+ const dist = await this.loader.loadLatest(plugin, projectRoot);
+ if (compareSemver(dist.manifest.version, plugin.version) <= 0) return null;
+ const { files, componentPaths } = await this.materializeFiles(
+ dist,
+ toolId,
+ plugin,
+ projectRoot
+ );
+ const home = (this.builtDeps?.homedir ?? nodeHomedir)();
+ const oldSafe = await userScopeFilesSafeToDelete(this.fs, logger, plugin, toolId, home);
+ if (oldSafe.size !== plugin.files.size) {
+ throw new Error(
+ `${toolId}: '${plugin.name}' has unsafe recorded files; update refused without dropping its machine claim.`
+ );
+ }
+ await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, files, home);
+ const baseDir = resolveBaseDirFromRecord("user", toolId, projectRoot, () => home);
+ await this.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files);
+ return {
+ plugin,
+ toolId,
+ home,
+ baseDir,
+ files,
+ oldSafe,
+ next: InstalledPlugin.fromDistribution(
+ dist,
+ plugin.source,
+ files,
+ "user",
+ componentPaths,
+ plugin.marketplace
+ ).withDependents(plugin.dependents),
+ };
+ }
+
+ async applyUpdate(plan: PlannedUserPluginFileUpdate, logger: Logger): Promise {
+ const oldSafe = await userScopeFilesSafeToDelete(
+ this.fs,
+ logger,
+ plan.plugin,
+ plan.toolId,
+ plan.home
+ );
+ if (oldSafe.size !== plan.plugin.files.size || oldSafe.size !== plan.oldSafe.size) {
+ throw new Error(
+ `${plan.toolId}: '${plan.plugin.name}' has unsafe recorded files since preflight; update refused before writing.`
+ );
+ }
+ await assertUserScopeWriteBoundary(
+ this.fs,
+ plan.toolId,
+ plan.plugin.name,
+ [...plan.files],
+ plan.home
+ );
+ await this.assertNewFilesDoNotCollide(plan.plugin, plan.toolId, plan.baseDir, plan.files);
+ await writePluginFiles([...plan.files], plan.baseDir, this.fs);
+ const newPaths = new Set(plan.files.map((file) => file.relativePath));
+ await deleteOldFiles(
+ new Map([...plan.oldSafe].filter(([path]) => !newPaths.has(path))),
+ plan.baseDir,
+ this.fs
+ );
+ return plan.next;
+ }
+
+ private async assertNewFilesDoNotCollide(
+ plugin: InstalledPlugin,
+ toolId: AiToolId,
+ baseDir: string,
+ files: readonly InstallationFile[]
+ ): Promise {
+ for (const file of files) {
+ if (plugin.files.has(file.relativePath)) continue;
+ if (await this.fs.fileExists(join(baseDir, file.relativePath))) {
+ throw new Error(
+ `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.`
+ );
+ }
+ }
+ }
+
+ private async materializeFiles(
+ dist: PluginDistribution,
+ toolId: AiToolId,
+ plugin: InstalledPlugin,
+ projectRoot: string
+ ): Promise<{ files: InstallationFile[]; componentPaths: ReadonlyMap }> {
+ const marketplace =
+ plugin.marketplace === undefined
+ ? undefined
+ : (await this.builtDeps?.marketplaceRegistry.list(projectRoot))?.find(
+ (entry) => entry.name === plugin.marketplace
+ );
+ if (marketplace !== undefined && this.builtDeps !== undefined) {
+ const { builtDir } = await this.builtDeps.ensureBuilt.execute({
+ projectRoot,
+ marketplace,
+ target: toolId,
+ mode: frameworkBuildModeFor(toolId),
+ });
+ const built = await readBuiltUserPluginFiles(this.fs, this.hasher, builtDir, plugin.name);
+ const files =
+ resolvePluginsCapability(toolId)?.hooksDestination === "project"
+ ? withoutHooksPrefix(built, plugin.name)
+ : built;
+ return { files, componentPaths: new Map() };
+ }
+ const toolConfig = getToolConfig(toolId);
+ const forGlobalFiles =
+ resolvePluginsCapability(toolId)?.hooksDestination === "project" ? withoutHooks(dist) : dist;
+ const translated = new PluginContentTranslator(this.hasher).translateWithComponentPaths(
+ forGlobalFiles,
+ toolConfig
+ );
+ return { files: translated.files, componentPaths: translated.componentPaths };
+ }
+}
diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts b/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts
new file mode 100644
index 000000000..88ab22c8c
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts
@@ -0,0 +1,40 @@
+import type { FileReader } from "../../../../kernel/ports/file-reader.js";
+import type { AiToolId } from "../../../../kernel/tool.js";
+import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
+
+/** Release only this project's claim; machine files remain until explicit user cleanup. */
+export async function detachUserPlugin(
+ repo: ManifestRepository | undefined,
+ fs: FileReader,
+ toolId: AiToolId,
+ name: string,
+ projectRoot: string
+): Promise {
+ if (repo === undefined) return;
+ if (repo.withExclusiveAccess !== undefined) {
+ return repo.withExclusiveAccess(() =>
+ detachUserPluginUnlocked(repo, fs, toolId, name, projectRoot)
+ );
+ }
+ return detachUserPluginUnlocked(repo, fs, toolId, name, projectRoot);
+}
+
+async function detachUserPluginUnlocked(
+ repo: ManifestRepository,
+ fs: FileReader,
+ toolId: AiToolId,
+ name: string,
+ projectRoot: string
+): Promise {
+ const manifest = await repo.load();
+ if (manifest === null) return;
+ const plugin = manifest.getPlugins(toolId).find((p) => p.name === name);
+ if (plugin === undefined || plugin.scope !== "user") return;
+ const root = await fs.realpath(projectRoot);
+ if (!plugin.dependents.includes(root)) return;
+ manifest.updatePlugin(
+ toolId,
+ plugin.withDependents(plugin.dependents.filter((dependent) => dependent !== root))
+ );
+ await repo.save(manifest);
+}
diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts
new file mode 100644
index 000000000..2978e3fcf
--- /dev/null
+++ b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts
@@ -0,0 +1,120 @@
+import { PluginNotFoundError } from "../../../../kernel/errors.js";
+import type { Logger } from "../../../../kernel/ports/logger.js";
+import { Manifest } from "../../domain/manifest.js";
+import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js";
+import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
+import { resolvePluginToolIds } from "../plugin/plugin-target-resolution.js";
+import type { PluginUpdateOptions } from "../plugin/plugin-update-use-case.js";
+import type { NativeHostRegistrationGate } from "./native-host-registration-gate.js";
+import type { UserPluginFileUpdater } from "./user-plugin-file-updater.js";
+
+export class UserPluginUpdateUseCase {
+ constructor(
+ private readonly repo: ManifestRepository,
+ private readonly fileUpdater: UserPluginFileUpdater,
+ private readonly logger: Logger,
+ private readonly nativeHost: NativeHostRegistrationGate
+ ) {}
+
+ async execute(options: PluginUpdateOptions): Promise {
+ const run = () => this.updateLocked(options);
+ return this.repo.withExclusiveAccess === undefined ? run() : this.repo.withExclusiveAccess(run);
+ }
+
+ private async updateLocked(options: PluginUpdateOptions): Promise {
+ const loaded = await this.repo.load();
+ if (loaded === null)
+ throw new Error(
+ "No machine manifest: cannot prove AIDD ownership for user-scope plugin update."
+ );
+ const manifest = Manifest.fromJSON(loaded.toJSON());
+ const toolIds = resolvePluginToolIds(options.toolIds, manifest);
+ const targets = toolIds.flatMap((toolId) =>
+ manifest
+ .getPlugins(toolId)
+ .filter(
+ (plugin) =>
+ plugin.scope === "user" &&
+ (options.pluginNames === undefined || options.pluginNames.includes(plugin.name))
+ )
+ .map((plugin) => ({ toolId, plugin }))
+ );
+ const nativeTargets = toolIds.flatMap((toolId) =>
+ (manifest.getNativeRegistrations(toolId)?.pluginClaims ?? [])
+ .filter(
+ (claim) =>
+ options.pluginNames === undefined ||
+ options.pluginNames.some((name) =>
+ name.includes("@") ? claim.ref === name : claim.ref.startsWith(`${name}@`)
+ )
+ )
+ .map((claim) => ({ toolId, claim, registrations: manifest.getNativeRegistrations(toolId) }))
+ );
+ if (options.pluginNames !== undefined && targets.length === 0 && nativeTargets.length === 0) {
+ throw new PluginNotFoundError(options.pluginNames.join(", "));
+ }
+ if (
+ options.pluginNames?.some(
+ (name) =>
+ !name.includes("@") &&
+ nativeTargets.filter(({ claim }) => claim.ref.startsWith(`${name}@`)).length > 1
+ )
+ ) {
+ throw new Error(
+ "Multiple native catalogues match this plugin name; use an exact @ ref for targeted update."
+ );
+ }
+ const filePlans = await Promise.all(
+ targets.map(async ({ toolId, plugin }) => ({
+ toolId,
+ plugin,
+ plan: await this.fileUpdater.planUpdate(plugin, toolId, options.projectRoot, this.logger),
+ }))
+ );
+ const nativeUpdates = await Promise.all(
+ nativeTargets.map(async ({ toolId, claim, registrations }) => ({
+ toolId,
+ claim,
+ activator: await this.nativeHost.requireTargetedUpdate(
+ toolId,
+ claim,
+ options.projectRoot,
+ registrations
+ ),
+ }))
+ );
+ const updated: string[] = [];
+ for (const { toolId, claim, activator } of nativeUpdates) {
+ if (claim.dependents.length > 0)
+ this.logger.warn(
+ `${toolId}: updating native ref '${claim.ref}' affects ${claim.dependents.join(", ")}; projects must refresh their local integrations afterward.`
+ );
+ if (activator.updatePlugin === undefined)
+ throw new Error(
+ `${toolId}: targeted native update became unavailable; canonical claims retained.`
+ );
+ activator.updatePlugin(claim.ref);
+ updated.push(claim.ref);
+ }
+ for (const { toolId, plugin, plan } of filePlans) {
+ if (plan === null) continue;
+ if (plugin.dependents.length > 0)
+ this.logger.warn(
+ `${toolId}: updating user-scope '${plugin.name}' affects ${plugin.dependents.join(", ")}; each project must refresh its own integration afterward.`
+ );
+ let next: InstalledPlugin;
+ try {
+ next = await this.fileUpdater.applyUpdate(plan, this.logger);
+ } catch (error) {
+ throw new Error(
+ `${toolId}: user plugin file update failed after preflight; a native ref may already have been updated and file bytes may be partial. Canonical claim not saved; reconcile manually before retrying.`,
+ { cause: error }
+ );
+ }
+ manifest.updatePlugin(toolId, next);
+ updated.push(plugin.name);
+ }
+ if (updated.length > 0) await this.repo.save(manifest);
+ return updated;
+ }
+}
diff --git a/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts
index a5ca4087a..f69c8cc8b 100644
--- a/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts
+++ b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts
@@ -3,6 +3,7 @@ import { join } from "node:path";
import {
DuplicatePluginError,
MissingPluginMetadataError,
+ ToolNotInManifestError,
VersionMismatchError,
} from "../../../../kernel/errors.js";
import type { InstallationFile } from "../../../../kernel/file.js";
@@ -20,15 +21,24 @@ import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js";
import { PluginContentTranslator } from "../../../translate/domain/content-translator.js";
import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js";
import type { ReadonlySkipList } from "../../../translate/domain/plugin-translation-skip.js";
-import type { Manifest } from "../../domain/manifest.js";
-import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js";
+import { Manifest } from "../../domain/manifest.js";
+import {
+ InstalledPlugin,
+ type ProjectHooksProvenance,
+} from "../../domain/plugins/installed-plugin.js";
import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js";
import type { PluginTranslator } from "../framework/translator/plugin-translator.js";
import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js";
+import { assertProjectMcpEntriesRemovable } from "../ownership/project-plugin-cleanup.js";
import type { EnsureBuiltMarketplace } from "../shared/ensure-built-marketplace-use-case.js";
+import { assertProjectHooksRemovable } from "../shared/remove-project-hooks.js";
import { loadPluginManifest, writePluginFiles } from "./plugin-helpers.js";
-import { resolvePluginToolIds, resolveScopeForInstall } from "./plugin-target-resolution.js";
+import {
+ resolveBaseDirFromRecord,
+ resolvePluginToolIds,
+ resolveScopeForInstall,
+} from "./plugin-target-resolution.js";
export interface PluginAddOptions {
source: PluginSource;
@@ -55,18 +65,38 @@ export class PluginAddUseCase implements PluginAdd {
private readonly hasher: Hasher,
private readonly logger: Logger,
private readonly marketplaceRegistry: MarketplaceRegistry,
- private readonly ensureBuilt: EnsureBuiltMarketplace
+ private readonly ensureBuilt: EnsureBuiltMarketplace,
+ private readonly userManifestRepo: ManifestRepository
) {}
async execute(options: PluginAddOptions): Promise {
+ if (this.userManifestRepo.withExclusiveAccess !== undefined) {
+ return this.userManifestRepo.withExclusiveAccess(() => this.executeLocked(options));
+ }
+ return this.executeLocked(options);
+ }
+
+ private async executeLocked(options: PluginAddOptions): Promise {
const { source, toolIds, projectRoot, marketplace } = options;
- const manifest = await loadPluginManifest(this.manifestRepo);
+ const manifest = Manifest.fromJSON((await loadPluginManifest(this.manifestRepo)).toJSON());
+ const existingMachine = await this.userManifestRepo.load();
+ const machine =
+ existingMachine === null ? Manifest.create() : Manifest.fromJSON(existingMachine.toJSON());
const resolvedToolIds = resolvePluginToolIds(toolIds, manifest);
+ const installedBefore = pluginsInstalledBefore(manifest, resolvedToolIds);
if (marketplace !== undefined && (await this.isGithubMarketplace(marketplace, projectRoot))) {
- await this.addGithubMarketplacePlugin(options, resolvedToolIds, manifest);
+ await this.addGithubMarketplacePlugin(options, resolvedToolIds, manifest, machine);
} else {
- await this.addLocalPlugin(options, resolvedToolIds, manifest, source, projectRoot);
+ await this.addLocalPlugin(options, resolvedToolIds, manifest, source, projectRoot, machine);
}
+ const claimed = await this.recordUserScopeOwnership(
+ manifest,
+ machine,
+ resolvedToolIds,
+ projectRoot,
+ installedBefore
+ );
+ if (claimed) await this.userManifestRepo.save(machine);
await this.manifestRepo.save(manifest);
}
@@ -79,10 +109,18 @@ export class PluginAddUseCase implements PluginAdd {
private async addGithubMarketplacePlugin(
options: PluginAddOptions,
toolIds: AiToolId[],
- manifest: Manifest
+ manifest: Manifest,
+ machine: Manifest
): Promise {
const { pluginMetadata } = options;
if (pluginMetadata === undefined) throw new MissingPluginMetadataError();
+ if (options.replace === true)
+ await this.assertExistingContributionsUnchanged(
+ pluginMetadata.name,
+ toolIds,
+ manifest,
+ options.projectRoot
+ );
if (options.replace === true) this.dropExistingPlugin(pluginMetadata.name, toolIds, manifest);
else this.validateNoDuplicates(pluginMetadata.name, toolIds, manifest);
const adapterMap = this.buildAdapterMap(toolIds);
@@ -94,7 +132,8 @@ export class PluginAddUseCase implements PluginAdd {
flatToolIds,
manifest,
options.source,
- options.projectRoot
+ options.projectRoot,
+ machine
);
}
await this.registerNativeGithubPlugins(options, nativeToolIds, manifest);
@@ -156,14 +195,27 @@ export class PluginAddUseCase implements PluginAdd {
resolvedToolIds: AiToolId[],
manifest: Manifest,
source: PluginSource,
- projectRoot: string
+ projectRoot: string,
+ machine: Manifest
): Promise {
const { marketplace, requiredVersion, replace, pluginMetadata } = options;
const read = await this.readDistribution(source, projectRoot);
const dist = pluginMetadata === undefined ? read : read.withStrict(pluginMetadata.strict);
const pluginName = dist.manifest.name;
this.assertPluginVersionMatches(pluginName, dist.manifest.version, requiredVersion);
- const { prevMcpMap } = this.prepareForInstall(pluginName, resolvedToolIds, manifest, replace);
+ if (replace === true)
+ await this.assertExistingContributionsUnchanged(
+ pluginName,
+ resolvedToolIds,
+ manifest,
+ projectRoot
+ );
+ const { prevMcpMap, prevHooksMap } = this.prepareForInstall(
+ pluginName,
+ resolvedToolIds,
+ manifest,
+ replace
+ );
await this.installPluginForAllTools(
dist,
resolvedToolIds,
@@ -171,23 +223,49 @@ export class PluginAddUseCase implements PluginAdd {
projectRoot,
manifest,
marketplace,
- prevMcpMap
+ prevMcpMap,
+ prevHooksMap,
+ machine
);
}
+ private async assertExistingContributionsUnchanged(
+ pluginName: string,
+ toolIds: readonly AiToolId[],
+ manifest: Manifest,
+ projectRoot: string
+ ): Promise {
+ for (const toolId of toolIds) {
+ const previous = manifest.getPlugins(toolId).find((plugin) => plugin.name === pluginName);
+ if (previous === undefined) continue;
+ await assertProjectMcpEntriesRemovable(this.fs, previous, toolId, projectRoot);
+ await assertProjectHooksRemovable(this.fs, previous, toolId, projectRoot);
+ }
+ }
+
private prepareForInstall(
pluginName: string,
toolIds: AiToolId[],
manifest: Manifest,
replace: boolean | undefined
- ): { prevMcpMap: Map> } {
+ ): {
+ prevMcpMap: Map>;
+ prevHooksMap: Map;
+ } {
const prevMcpMap = this.collectPreviousMcpEntries(pluginName, toolIds, manifest);
+ const prevHooksMap = new Map();
+ for (const toolId of toolIds) {
+ const projectHooks = manifest
+ .getPlugins(toolId)
+ .find((p) => p.name === pluginName)?.projectHooks;
+ if (projectHooks !== undefined) prevHooksMap.set(toolId, projectHooks);
+ }
if (replace === true) {
this.dropExistingPlugin(pluginName, toolIds, manifest);
} else {
this.validateNoDuplicates(pluginName, toolIds, manifest);
}
- return { prevMcpMap };
+ return { prevMcpMap, prevHooksMap };
}
private async installPluginForAllTools(
@@ -197,11 +275,24 @@ export class PluginAddUseCase implements PluginAdd {
projectRoot: string,
manifest: Manifest,
marketplace: string | undefined,
- prevMcpMap: Map>
+ prevMcpMap: Map>,
+ prevHooksMap: Map,
+ machine: Manifest
): Promise {
const allSkipped: ReadonlySkipList[] = [];
const allNotices: ReadonlyNoticeList[] = [];
for (const toolId of toolIds) {
+ const foreignDir = await this.userScopeDirNotInstalledHere(
+ dist.manifest.name,
+ toolId,
+ projectRoot,
+ machine
+ );
+ if (foreignDir !== undefined) {
+ this.logger.warn(
+ `${toolId}: ${foreignDir} was already there and this project did not install it — left as found and not tracked, so this project's clean will not delete it.`
+ );
+ }
const prev = prevMcpMap.get(toolId) ?? new Map();
const { skipped, notices } = await this.addPluginForTool(
dist,
@@ -210,7 +301,10 @@ export class PluginAddUseCase implements PluginAdd {
projectRoot,
manifest,
marketplace,
- prev
+ prev,
+ foreignDir !== undefined ||
+ machine.getPlugins(toolId).some((p) => p.name === dist.manifest.name),
+ prevHooksMap.get(toolId)
);
allSkipped.push(skipped);
allNotices.push(notices);
@@ -219,6 +313,54 @@ export class PluginAddUseCase implements PluginAdd {
this.emitInstallNotices(allNotices.flat());
}
+ private async userScopeDirNotInstalledHere(
+ pluginName: string,
+ toolId: AiToolId,
+ projectRoot: string,
+ machine: Manifest
+ ): Promise {
+ if (resolveScopeForInstall(toolId) !== "user") return undefined;
+ if (machine.getPlugins(toolId).some((p) => p.name === pluginName)) return undefined;
+ const dir = join(
+ resolveBaseDirFromRecord("user", toolId, projectRoot, nodeHomedir),
+ pluginName
+ );
+ const present = await this.fs.listFilesRecursive(dir);
+ return present.length > 0 ? dir : undefined;
+ }
+
+ private async recordUserScopeOwnership(
+ project: Manifest,
+ machine: Manifest,
+ toolIds: readonly AiToolId[],
+ projectRoot: string,
+ installedBefore: PluginsInstalledBefore
+ ): Promise {
+ const root = await this.fs.realpath(projectRoot);
+ let claimed = false;
+ for (const toolId of toolIds) {
+ for (const plugin of project.getPlugins(toolId)) {
+ if (plugin.scope !== "user") continue;
+ const owned = machine.getPlugins(toolId).find((p) => p.name === plugin.name);
+ if (owned === undefined && installedBefore.has(installedKey(toolId, plugin.name))) continue;
+ if (owned === undefined && plugin.files.size === 0) continue;
+ if (!machine.hasTool(toolId)) {
+ const version = project.getToolVersion(toolId);
+ if (version === undefined) throw new ToolNotInManifestError(toolId);
+ machine.addTool(toolId, version, []);
+ }
+ const canonical = (
+ owned ?? InstalledPlugin.withMcpEntries(plugin, new Map())
+ ).withDependents([...(owned?.dependents ?? []), root]);
+ if (owned === undefined) machine.addPlugin(toolId, canonical);
+ else machine.updatePlugin(toolId, canonical);
+ project.updatePlugin(toolId, plugin.withFiles(new Map()));
+ claimed = true;
+ }
+ }
+ return claimed;
+ }
+
private collectPreviousMcpEntries(
pluginName: string,
toolIds: AiToolId[],
@@ -264,7 +406,9 @@ export class PluginAddUseCase implements PluginAdd {
projectRoot: string,
manifest: Manifest,
marketplace: string | undefined,
- previousMcpEntries: ReadonlyMap = new Map()
+ previousMcpEntries: ReadonlyMap,
+ userScopeDirTaken: boolean,
+ previousProjectHooks?: ProjectHooksProvenance
): Promise<{ skipped: ReadonlySkipList; notices: ReadonlyNoticeList }> {
const toolConfig = getToolConfig(toolId);
if (!isAiTool(toolConfig)) return { skipped: [], notices: [] };
@@ -277,7 +421,9 @@ export class PluginAddUseCase implements PluginAdd {
projectRoot,
manifest,
marketplace,
- previousMcpEntries
+ previousMcpEntries,
+ userScopeDirTaken,
+ previousProjectHooks
);
return { ...result, notices: [] };
}
@@ -368,3 +514,20 @@ export class PluginAddUseCase implements PluginAdd {
});
}
}
+
+type PluginsInstalledBefore = ReadonlySet;
+
+function pluginsInstalledBefore(
+ manifest: Manifest,
+ toolIds: readonly AiToolId[]
+): PluginsInstalledBefore {
+ return new Set(
+ toolIds.flatMap((toolId) =>
+ manifest.getPlugins(toolId).map((p) => installedKey(toolId, p.name))
+ )
+ );
+}
+
+function installedKey(toolId: AiToolId, pluginName: string): string {
+ return `${toolId}/${pluginName}`;
+}
diff --git a/cli/src/contexts/framework/application/plugin/plugin-helpers.ts b/cli/src/contexts/framework/application/plugin/plugin-helpers.ts
index 6add4784a..49f448b25 100644
--- a/cli/src/contexts/framework/application/plugin/plugin-helpers.ts
+++ b/cli/src/contexts/framework/application/plugin/plugin-helpers.ts
@@ -50,8 +50,10 @@ export async function deletePluginFilesForTool(
scope: PluginScope,
toolId: AiToolId,
projectRoot: string,
- fs: FileWriter
+ fs: FileWriter,
+ ownerScope: "project" | "user" = "project"
): Promise {
+ if (scope === "user" && ownerScope !== "user") return [];
const baseDir = resolveBaseDirFromRecord(scope, toolId, projectRoot, nodeHomedir);
const deleted: string[] = [];
for (const relativePath of files.keys()) {
@@ -100,7 +102,10 @@ export async function materializeViaTranslator(
plugin.source,
projectRoot,
manifest,
- plugin.marketplace
+ plugin.marketplace,
+ plugin.mcpEntries,
+ false,
+ plugin.projectHooks
);
return written ?? 0;
}
diff --git a/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts
index ae055e6dd..66d5cd22d 100644
--- a/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts
+++ b/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts
@@ -1,16 +1,24 @@
import { homedir as nodeHomedir } from "node:os";
import { dirname, join } from "node:path";
-import { NativePluginCliError, PluginNotFoundError } from "../../../../kernel/errors.js";
+import {
+ ActiveMachineDependentsError,
+ NativePluginCliError,
+ PluginNotFoundError,
+} from "../../../../kernel/errors.js";
import type { FileReader } from "../../../../kernel/ports/file-reader.js";
import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
import type { Logger } from "../../../../kernel/ports/logger.js";
import { resolveHomeDir } from "../../../../kernel/reading/home-dir.js";
import type { MarketplaceScope } from "../../../../kernel/scope.js";
-import type { AiToolId } from "../../../../kernel/tool.js";
+import type { AiToolId, ToolId } from "../../../../kernel/tool.js";
import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js";
import type { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js";
import { unmergeOpencodeMcp } from "../../../tools/domain/formats/opencode-mcp-merge.js";
-import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js";
+import type {
+ HostPluginRegistryReader,
+ HostPluginRegistryReading,
+} from "../../../tools/domain/ports/host-plugin-registry-reader.js";
+import type { NativeMarketplaceSourceReader } from "../../../tools/domain/ports/native-marketplace-source-reader.js";
import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js";
import {
getToolConfig,
@@ -24,8 +32,16 @@ import type { Manifest } from "../../domain/manifest.js";
import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js";
import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
import type { UserSourceReferences } from "../../domain/ports/user-source-references.js";
+import { inspectNativeMarketplaceSource } from "../ownership/native-marketplace-source-proof.js";
+import {
+ detachNativePluginRefs,
+ isMachineOwnedNativeRef,
+} from "../ownership/native-plugin-ownership.js";
+import { assertProjectPathWithinRoot } from "../ownership/project-path-boundary.js";
+import { assertProjectMcpEntriesRemovable } from "../ownership/project-plugin-cleanup.js";
+import { detachUserPlugin } from "../ownership/user-plugin-ownership.js";
import { resolveCacheCandidate } from "../shared/purge-declared-cache.js";
-import { removeProjectHooks } from "../shared/remove-project-hooks.js";
+import { assertProjectHooksRemovable, removeProjectHooks } from "../shared/remove-project-hooks.js";
import { resolveUninstallScopeOrder } from "../shared/resolve-uninstall-scope.js";
import {
describeGuardedPluginRefMessage,
@@ -35,7 +51,8 @@ import {
resolveProjectRootForReferences,
toleratingUnreadableSourceReferences,
} from "../shared/shared-source-reference-support.js";
-import { loadPluginManifest } from "./plugin-helpers.js";
+import { userScopeFilesSafeToDelete } from "../shared/user-scope-plugin-files.js";
+import { deletePluginFilesForTool, loadPluginManifest } from "./plugin-helpers.js";
import {
isFrameworkPrimeFlatMcp,
resolveBaseDirFromRecord,
@@ -46,6 +63,7 @@ export interface PluginRemoveOptions {
pluginName: string;
toolIds: AiToolId[] | "all";
projectRoot: string;
+ scope?: "project" | "user";
}
export class PluginRemoveUseCase {
@@ -69,16 +87,230 @@ export class PluginRemoveUseCase {
/** Resolves the scope this project's own registry recorded for `plugin.marketplace` — the one
* fact `frameworkSourceIsShared` needs and a plugin record does not carry. Absent treats every
* marketplace as not shared. */
- private readonly marketplaceRegistry?: MarketplaceRegistry
+ private readonly marketplaceRegistry?: MarketplaceRegistry,
+ private readonly userManifestRepo?: ManifestRepository,
+ private readonly nativeMarketplaceSources: ReadonlyMap<
+ AiToolId,
+ NativeMarketplaceSourceReader
+ > = new Map()
) {}
async execute(options: PluginRemoveOptions): Promise {
+ if (options.scope === "user") {
+ if (this.userManifestRepo === undefined)
+ throw new Error("User manifest repository is required for user-scope plugin removal.");
+ if (this.userManifestRepo.withExclusiveAccess !== undefined) {
+ return this.userManifestRepo.withExclusiveAccess(() => this.removeUserPlugin(options));
+ }
+ return this.removeUserPlugin(options);
+ }
const { pluginName, toolIds, projectRoot } = options;
const manifest = await loadPluginManifest(this.manifestRepo);
const resolvedToolIds = resolvePluginToolIds(toolIds, manifest);
+ for (const toolId of resolvedToolIds) {
+ const plugin = manifest.getPlugins(toolId).find((p) => p.name === pluginName);
+ if (plugin === undefined) continue;
+ await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, projectRoot);
+ await assertProjectHooksRemovable(this.fs, plugin, toolId, projectRoot);
+ await this.assertProjectNativeSource(
+ plugin,
+ toolId,
+ projectRoot,
+ manifest.getNativeRegistrations(toolId)
+ );
+ }
+ const userTools = resolvedToolIds.filter((toolId) =>
+ manifest.getPlugins(toolId).some((p) => p.name === pluginName && p.scope === "user")
+ );
+ const nativeRefs = new Map();
+ for (const toolId of resolvedToolIds) {
+ const plugin = manifest.getPlugins(toolId).find((p) => p.name === pluginName);
+ const registrations = manifest.getNativeRegistrations(toolId);
+ if (plugin?.marketplace === undefined || registrations === undefined) continue;
+ const hostName = registrations.marketplaces.find(
+ (m) => m.alias === plugin.marketplace
+ )?.hostName;
+ if (hostName !== undefined) nativeRefs.set(toolId, [`${pluginName}@${hostName}`]);
+ }
const removed = await this.removeFromTools(pluginName, resolvedToolIds, projectRoot, manifest);
if (!removed) throw new PluginNotFoundError(pluginName);
await this.manifestRepo.save(manifest);
+ await detachNativePluginRefs(this.userManifestRepo, this.fs, projectRoot, nativeRefs);
+ for (const toolId of userTools) {
+ await detachUserPlugin(this.userManifestRepo, this.fs, toolId, pluginName, projectRoot);
+ }
+ }
+
+ private async assertProjectNativeSource(
+ plugin: InstalledPlugin,
+ toolId: AiToolId,
+ projectRoot: string,
+ registrations: NativeRegistrations | undefined
+ ): Promise {
+ const activation = resolvePluginsCapability(toolId)?.nativeActivation;
+ if (activation == null || plugin.marketplace === undefined) return;
+ const activator = this.activators.get(activation.binary);
+ if (activator === undefined || !activator.isAvailable()) return;
+ const registration = registrations?.marketplaces.find(
+ (entry) => entry.alias === plugin.marketplace
+ );
+ if (registration === undefined)
+ throw new Error(
+ `${toolId}: '${plugin.name}' has no recorded native catalogue source for '${plugin.marketplace}'; reconcile manually before removal.`
+ );
+ const ref = `${plugin.name}@${registration.hostName}`;
+ if (
+ pluginEnablementIsMachineGlobal(toolId) &&
+ !(await isMachineOwnedNativeRef(this.userManifestRepo, toolId, ref))
+ )
+ throw new Error(
+ `${toolId}: '${ref}' is an unclaimed machine-global host ref; canonical machine claim required before removal.`
+ );
+ if (
+ registrations?.pluginRefs.includes(ref) !== true &&
+ this.activators.get(activation.binary)?.enablesPlugins() !== true
+ )
+ throw new Error(
+ `${toolId}: '${ref}' is not a ref this project enabled; reconcile manually before removal.`
+ );
+ const proof = await inspectNativeMarketplaceSource(
+ this.nativeMarketplaceSources.get(toolId),
+ projectRoot,
+ registration
+ );
+ if (proof.status !== "owned") {
+ throw new Error(
+ proof.reason ??
+ `${toolId}: catalogue '${registration.hostName}' has unproven host source; reconcile manually.`
+ );
+ }
+ if (pluginEnablementIsMachineGlobal(toolId) || registrations?.pluginRefs.includes(ref) !== true)
+ return;
+ const reader = this.hostPluginRegistries.get(toolId);
+ if (reader === undefined)
+ throw new Error(
+ `${toolId}: '${ref}' has no readable host plugin registry; uninstall refused before project changes.`
+ );
+ let reading: HostPluginRegistryReading;
+ try {
+ reading = await reader.read(projectRoot);
+ } catch (error) {
+ throw new Error(
+ `${toolId}: '${ref}' host plugin registry read failed (${error instanceof Error ? error.message : String(error)}); uninstall refused.`
+ );
+ }
+ const current = reading.refs?.get(ref);
+ if (current?.enabled !== true || (current.scope !== "project" && current.scope !== "user"))
+ throw new Error(
+ `${toolId}: '${ref}' is not provably enabled with an exact host scope (${reading.unreadable ?? reading.location}); uninstall refused.`
+ );
+ }
+
+ private async removeUserPlugin(options: PluginRemoveOptions): Promise {
+ const repo = this.userManifestRepo;
+ if (repo === undefined)
+ throw new Error("User manifest repository is required for user-scope plugin removal.");
+ const manifest = await loadPluginManifest(repo);
+ const toolIds = resolvePluginToolIds(options.toolIds, manifest);
+ const targets = toolIds.flatMap((toolId) => {
+ const plugin = manifest
+ .getPlugins(toolId)
+ .find((p) => p.name === options.pluginName && p.scope === "user");
+ return plugin === undefined ? [] : [{ toolId, plugin }];
+ });
+ const nativeTargets = toolIds.flatMap((toolId) => {
+ const registrations = manifest.getNativeRegistrations(toolId);
+ return (registrations?.pluginClaims ?? [])
+ .filter((claim) =>
+ options.pluginName.includes("@")
+ ? claim.ref === options.pluginName
+ : claim.ref.startsWith(`${options.pluginName}@`)
+ )
+ .map((claim) => ({ toolId, registrations: registrations as NativeRegistrations, claim }));
+ });
+ if (targets.length === 0 && nativeTargets.length === 0)
+ throw new PluginNotFoundError(options.pluginName);
+ if (!options.pluginName.includes("@") && nativeTargets.length > 1) {
+ throw new Error(
+ `Native plugin '${options.pluginName}' has multiple catalogues; use the exact @ ref.`
+ );
+ }
+ const dependents = [
+ ...new Set([
+ ...targets.flatMap(({ plugin }) => plugin.dependents),
+ ...nativeTargets.flatMap(({ claim }) => claim.dependents),
+ ]),
+ ];
+ if (dependents.length > 0) {
+ throw new ActiveMachineDependentsError(
+ `user-scope plugin '${options.pluginName}'`,
+ dependents
+ );
+ }
+ const safeFiles = await Promise.all(
+ targets.map(async ({ toolId, plugin }) => {
+ const files = await userScopeFilesSafeToDelete(
+ this.fs,
+ this.logger,
+ plugin,
+ toolId,
+ resolveHomeDir()
+ );
+ if (files.size !== plugin.files.size)
+ throw new Error(
+ `Refusing partial removal of user-scope plugin '${plugin.name}': a tracked file escaped its boundary.`
+ );
+ return { toolId, plugin, files };
+ })
+ );
+ const nativeRemoval = await Promise.all(
+ nativeTargets.map(async ({ toolId, registrations, claim }) => {
+ const activator = this.activators.get(registrations.binary);
+ const reader = this.hostPluginRegistries.get(toolId);
+ if (activator === undefined || !activator.isAvailable() || reader === undefined) {
+ throw new Error(
+ `Cannot prove or remove AIDD-owned native ref '${claim.ref}': ${registrations.binary} CLI or host registry is unavailable.`
+ );
+ }
+ const onHost = (await reader.read(options.projectRoot)).refs?.get(claim.ref);
+ if (onHost?.enabled !== true)
+ throw new Error(
+ `Cannot prove native ref '${claim.ref}' is still enabled on ${registrations.binary}; no host mutation made.`
+ );
+ const catalogues = registrations.marketplaces.filter((registration) =>
+ claim.ref.endsWith(`@${registration.hostName}`)
+ );
+ if (catalogues.length !== 1) {
+ throw new Error(
+ `Native ref '${claim.ref}' has no exact canonical catalogue source proof; removal refused.`
+ );
+ }
+ const proof = await inspectNativeMarketplaceSource(
+ this.nativeMarketplaceSources.get(toolId),
+ options.projectRoot,
+ catalogues[0]
+ );
+ if (proof.status !== "owned") {
+ throw new Error(proof.reason ?? `Native ref '${claim.ref}' has unproven host source.`);
+ }
+ return { toolId, registrations, claim, activator, scope: onHost.scope ?? "user" };
+ })
+ );
+ for (const target of nativeRemoval) {
+ target.activator.uninstallPlugin(target.claim.ref, target.scope);
+ const claims =
+ target.registrations.pluginClaims?.filter((claim) => claim.ref !== target.claim.ref) ?? [];
+ manifest.setNativeRegistrations(target.toolId, {
+ ...target.registrations,
+ pluginRefs: target.registrations.pluginRefs.filter((ref) => ref !== target.claim.ref),
+ pluginClaims: claims,
+ });
+ }
+ for (const { toolId, plugin, files } of safeFiles) {
+ await deletePluginFilesForTool(files, "user", toolId, options.projectRoot, this.fs, "user");
+ manifest.removePlugin(toolId, plugin.name);
+ }
+ await repo.save(manifest);
}
private async removeFromTools(
@@ -92,13 +324,36 @@ export class PluginRemoveUseCase {
const plugins = manifest.getPlugins(toolId);
const plugin = plugins.find((p) => p.name === pluginName);
if (plugin === undefined) continue;
+ const registrations = manifest.getNativeRegistrations(toolId);
const baseDir = resolveBaseDirFromRecord(plugin.scope, toolId, projectRoot, nodeHomedir);
- const confirmed = await this.removeNativeActivation(plugin, toolId, projectRoot, manifest);
+ const confirmed = await this.removeNativeActivation(
+ plugin,
+ toolId,
+ projectRoot,
+ registrations
+ );
if (confirmed !== undefined)
- await this.purgeCachedPlugin(manifest, toolId, plugin, confirmed);
- await this.deletePluginFiles(plugin.files, baseDir);
+ await this.purgeCachedPlugin(registrations, toolId, plugin, confirmed);
+ if (plugin.scope !== "user") await this.deletePluginFiles(plugin.files, baseDir);
await this.removeMcpEntries(plugin, toolId, projectRoot);
- await removeProjectHooks(this.fs, pluginName, toolId, projectRoot);
+ await removeProjectHooks(this.fs, plugin, toolId, projectRoot);
+ const hostName = registrations?.marketplaces.find(
+ (m) => m.alias === plugin.marketplace
+ )?.hostName;
+ const ref = hostName === undefined ? undefined : `${plugin.name}@${hostName}`;
+ const canDetachRef =
+ confirmed === true ||
+ (ref !== undefined &&
+ pluginEnablementIsMachineGlobal(toolId) &&
+ (await isMachineOwnedNativeRef(this.userManifestRepo, toolId, ref)));
+ if (hostName !== undefined && registrations !== undefined && canDetachRef) {
+ manifest.setNativeRegistrations(toolId, {
+ ...registrations,
+ pluginRefs: registrations.pluginRefs.filter(
+ (ref) => ref !== `${plugin.name}@${hostName}`
+ ),
+ });
+ }
manifest.removePlugin(toolId, pluginName);
removed = true;
}
@@ -117,14 +372,21 @@ export class PluginRemoveUseCase {
plugin: InstalledPlugin,
toolId: AiToolId,
projectRoot: string,
- manifest: Manifest
+ registrations: NativeRegistrations | undefined
): Promise {
const nativeActivation = resolvePluginsCapability(toolId)?.nativeActivation;
if (nativeActivation == null || plugin.marketplace === undefined) return undefined;
const activator = this.activators.get(nativeActivation.binary);
if (activator === undefined) return undefined;
+ if (!activator.isAvailable()) {
+ const hostName = this.hostNameFor(registrations, plugin.marketplace) ?? plugin.marketplace;
+ this.logger.warn(
+ `${nativeActivation.binary} CLI not found on PATH — '${plugin.name}@${hostName}' was not uninstalled from the host; its ref may remain enabled after local removal.`
+ );
+ return false;
+ }
+ await this.assertProjectNativeSource(plugin, toolId, projectRoot, registrations);
const alias = plugin.marketplace;
- const registrations = manifest.getNativeRegistrations(toolId);
const registeredHostName = this.hostNameFor(registrations, alias);
if (registeredHostName === undefined && registrations !== undefined) {
this.logger.warn(
@@ -133,6 +395,20 @@ export class PluginRemoveUseCase {
}
const hostName = registeredHostName ?? alias;
const ref = `${plugin.name}@${hostName}`;
+ if (await isMachineOwnedNativeRef(this.userManifestRepo, toolId, ref)) {
+ this.logger.warn(
+ `${toolId}: '${ref}' is an AIDD-owned machine plugin ref — left enabled; this project's claim detaches after local removal.`
+ );
+ return undefined;
+ }
+ if (
+ registeredHostName !== undefined &&
+ activator.enablesPlugins() &&
+ registrations?.pluginRefs.includes(ref) !== true
+ ) {
+ this.logger.warn(`${toolId}: '${ref}' is not a ref this project enabled — left enabled.`);
+ return undefined;
+ }
const guardMessage = await this.describeGuardedPluginRef(
nativeActivation.binary,
toolId,
@@ -264,7 +540,7 @@ export class PluginRemoveUseCase {
* own `NativeRegistrations`, never the alias, which a host never learns.
*/
private async purgeCachedPlugin(
- manifest: Manifest,
+ registrations: NativeRegistrations | undefined,
toolId: AiToolId,
plugin: InstalledPlugin,
confirmed: boolean
@@ -272,7 +548,7 @@ export class PluginRemoveUseCase {
if (plugin.marketplace === undefined) return;
const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(resolveHomeDir());
if (cacheRoot === undefined) return;
- const hostName = this.hostNameFor(manifest.getNativeRegistrations(toolId), plugin.marketplace);
+ const hostName = this.hostNameFor(registrations, plugin.marketplace);
if (hostName === undefined) return;
const label = `${toolId}: cache for '${plugin.name}'`;
const candidate = await resolveCacheCandidate(
@@ -307,6 +583,7 @@ export class PluginRemoveUseCase {
const existing = await this.readExistingJson(outputPath);
if (existing === null) return;
const updated = unmergeOpencodeMcp(existing, plugin.mcpEntries);
+ await assertProjectPathWithinRoot(this.fs, projectRoot, outputPath);
await this.fs.writeFile(outputPath, updated);
}
diff --git a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts
index 9a79e9eeb..efeaf37f5 100644
--- a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts
+++ b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts
@@ -1,5 +1,6 @@
import { homedir as nodeHomedir } from "node:os";
import { join } from "node:path";
+import { InvalidPluginScopeError } from "../../../../kernel/errors.js";
import { PLUGIN_CACHE_SUBDIR } from "../../../../kernel/paths.js";
import type { FileReader } from "../../../../kernel/ports/file-reader.js";
import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
@@ -29,6 +30,7 @@ export interface PluginUpdateOptions {
pluginNames?: string[];
toolIds: AiToolId[] | "all";
projectRoot: string;
+ scope?: "project" | "user";
}
export class PluginUpdateUseCase {
@@ -42,6 +44,8 @@ export class PluginUpdateUseCase {
) {}
async execute(options: PluginUpdateOptions): Promise {
+ if (options.scope === "user")
+ throw new Error("User-scope plugin update requires UserPluginUpdateUseCase.");
const { pluginNames, toolIds, projectRoot } = options;
const manifest = await loadPluginManifest(this.manifestRepo);
const resolvedToolIds = resolvePluginToolIds(toolIds, manifest);
@@ -71,7 +75,7 @@ export class PluginUpdateUseCase {
const plugins = manifest.getPlugins(toolId);
const targets = pluginNames
? plugins.filter((p) => pluginNames.includes(p.name))
- : [...plugins];
+ : plugins.filter((plugin) => plugin.scope !== "user");
const updated: string[] = [];
for (const plugin of targets) {
const didUpdate = await this.updateOnePlugin(plugin, toolId, projectRoot, cacheDir, manifest);
@@ -87,6 +91,7 @@ export class PluginUpdateUseCase {
cacheDir: string,
manifest: Manifest
): Promise {
+ if (plugin.scope === "user") throw new InvalidPluginScopeError(toolId, "project", "user");
const localPath = await this.pluginFetcher.fetch(plugin.source, cacheDir, {
forceRefresh: true,
});
diff --git a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts
index 0d1a2053e..9ba14ac66 100644
--- a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts
+++ b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts
@@ -30,6 +30,16 @@ export class SetupMachineScopeUseCase {
async execute(flow: SetupFlow): Promise {
const source = await this.setupMarketplaceRegistration.resolveSourceIfNeeded(flow);
+ if (this.userManifestRepo.withExclusiveAccess !== undefined) {
+ return this.userManifestRepo.withExclusiveAccess(() => this.executeLocked(flow, source));
+ }
+ return this.executeLocked(flow, source);
+ }
+
+ private async executeLocked(
+ flow: SetupFlow,
+ source: Awaited>
+ ): Promise {
const isNew = await this.initUserManifest();
await this.setupMarketplaceRegistration.registerIfPresent(flow, source);
await this.registerUserScopeTools(flow);
diff --git a/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts b/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts
index 0dbc7c87f..daf70c45f 100644
--- a/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts
+++ b/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts
@@ -48,6 +48,7 @@ export class ApplyPluginFilesUseCase {
) {}
async execute(options: ApplyPluginFilesOptions): Promise {
+ if (options.plugin.scope === "user") return 0;
const localPath = await this.pluginFetcher.fetch(options.plugin.source, options.cacheDir);
const dist = await this.pluginDistributionReader.read(localPath);
const translator = this.resolveTranslator(options.toolConfig);
diff --git a/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts b/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts
index 0e5c2476e..46201ea06 100644
--- a/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts
+++ b/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts
@@ -45,8 +45,8 @@ export async function purgeAllNativeCaches(
* running, so containment alone proves the path cannot escape the declared root — never that the
* caller still owns what sits inside it. Two proofs, one per declaration:
*
- * - a profile declaring `marketplaceRegistry` (claude) is reread after the undo: the name gone
- * from that registry is the host's own admission nothing there resolves any more;
+ * - a profile declaring `marketplaceRegistry` (claude) also requires a confirmed host removal,
+ * then rereads the registry: a name gone says nothing there resolves any more;
* - a profile declaring `pluginCacheDir` alone (codex) drives a host that deletes the cached
* content itself and leaves only an empty shell — measured. Emptiness proves no data would be
* lost, never that this caller emptied it, so `removed` (the host's own confirmation) is
@@ -82,6 +82,12 @@ export async function purgeNativeMarketplaceCache(
);
return;
}
+ if (!removed) {
+ logger.warn(
+ `${binary}: cache for '${hostName}' left in place, its own removal was not confirmed: ${candidate}`
+ );
+ return;
+ }
await purgeOnceRegistryClears(fs, logger, reader, candidate, binary, hostName);
}
diff --git a/cli/src/contexts/framework/application/shared/remove-project-hooks.ts b/cli/src/contexts/framework/application/shared/remove-project-hooks.ts
index cc1e42906..c0674a0e2 100644
--- a/cli/src/contexts/framework/application/shared/remove-project-hooks.ts
+++ b/cli/src/contexts/framework/application/shared/remove-project-hooks.ts
@@ -1,4 +1,5 @@
-import { dirname, join } from "node:path";
+import { createHash } from "node:crypto";
+import { dirname, join, posix } from "node:path";
import type { FileReader } from "../../../../kernel/ports/file-reader.js";
import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
import type { AiToolId } from "../../../../kernel/tool.js";
@@ -7,43 +8,170 @@ import {
unmergeCursorProjectHooksJson,
} from "../../../tools/domain/formats/cursor-hooks-project-merge.js";
import { resolvePluginsCapability } from "../../../tools/domain/registry.js";
+import type {
+ InstalledPlugin,
+ ProjectHooksProvenance,
+} from "../../domain/plugins/installed-plugin.js";
+import { assertProjectPathWithinRoot } from "../ownership/project-path-boundary.js";
-/**
- * Undoes what `ProjectHooksMaterializer` wrote for one plugin: a tool declaring
- * `hooksDestination: "project"` (Cursor) merges a plugin's hooks into the project's own hooks file
- * rather than tracking them in `Plugin.files`, so removal needs an unmerge instead of a
- * baseDir-relative file delete. Both destinations are recomputed from `pluginName` alone, exactly
- * as install computed them, so there is no extra state to keep in sync.
- *
- * Returns whether anything was actually there to undo. A no-op for a tool declaring no
- * project-merged hooks destination.
- */
-export async function removeProjectHooks(
+type HookEntry = { command: string; [key: string]: unknown };
+
+/** Preflight the exact project hook entries and scripts installed by AIDD. Does not mutate. */
+export async function assertProjectHooksUnchanged(
+ fs: FileReader,
+ pluginName: string,
+ provenance: ProjectHooksProvenance | undefined,
+ toolId: AiToolId,
+ projectRoot: string
+): Promise<{ existing: string | null }> {
+ const cap = resolvePluginsCapability(toolId);
+ if (cap?.hooksDestination !== "project" || cap.projectHooksRelativePath === null) {
+ return { existing: null };
+ }
+ const hooksPath = join(projectRoot, cap.projectHooksRelativePath);
+ await assertProjectPathWithinRoot(fs, projectRoot, hooksPath);
+ const existing = await readExistingJson(fs, hooksPath);
+ const scriptDir = join(projectRoot, cursorProjectHooksScriptDir(pluginName));
+ await assertProjectPathWithinRoot(fs, projectRoot, scriptDir);
+ const scriptsPresent = (await fs.fileExists(scriptDir)) ? await fs.listDirectory(scriptDir) : [];
+ const currentEntries = existing === null ? [] : contributedEntries(existing, pluginName);
+ if (provenance === undefined) {
+ if (currentEntries.length > 0 || scriptsPresent.length > 0) {
+ throw new Error(
+ `Cursor project hooks for '${pluginName}' have an unproven legacy install digest; detach refused.`
+ );
+ }
+ return { existing };
+ }
+ const recorded = provenance.entries.map(
+ ({ event, command, digest }) => `${event}\u0000${command}\u0000${digest}`
+ );
+ const current = currentEntries.map(
+ ({ event, entry }) => `${event}\u0000${entry.command}\u0000${digestEntry(entry)}`
+ );
+ if (
+ provenance.entries.some(({ digest }) => !/^[0-9a-f]{32}$/.test(digest)) ||
+ recorded.sort().join("\n") !== current.sort().join("\n")
+ ) {
+ throw new Error(
+ `Cursor project hooks for '${pluginName}' were edited after install; detach refused.`
+ );
+ }
+ for (const [relativePath, digest] of provenance.scripts) {
+ assertScriptPath(pluginName, relativePath);
+ const path = join(projectRoot, relativePath);
+ await assertProjectPathWithinRoot(fs, projectRoot, path);
+ if (!(await fs.fileExists(path))) continue;
+ if (!/^[0-9a-f]{32}$/.test(digest)) {
+ throw new Error(
+ `Cursor hook script '${relativePath}' has an unproven install digest; detach refused.`
+ );
+ }
+ if ((await fs.readFileHash(path)).value !== digest) {
+ throw new Error(
+ `Cursor hook script '${relativePath}' was edited after install; detach refused.`
+ );
+ }
+ }
+ return { existing };
+}
+
+export async function assertProjectHooksRemovable(
+ fs: FileReader,
+ plugin: InstalledPlugin,
+ toolId: AiToolId,
+ projectRoot: string
+): Promise {
+ await assertProjectHooksUnchanged(fs, plugin.name, plugin.projectHooks, toolId, projectRoot);
+}
+
+/** Unmerge only verified hook entries and tracked scripts; never delete a directory by name. */
+export async function removeRecordedProjectHooks(
fs: FileReader & FileWriter,
pluginName: string,
+ provenance: ProjectHooksProvenance | undefined,
toolId: AiToolId,
projectRoot: string
): Promise {
- const pluginsCap = resolvePluginsCapability(toolId);
- if (pluginsCap?.hooksDestination !== "project") return false;
- const projectHooksRelativePath = pluginsCap.projectHooksRelativePath;
- if (projectHooksRelativePath === null) return false;
- const hooksPath = join(projectRoot, projectHooksRelativePath);
- const existing = await readExistingJson(fs, hooksPath);
- if (existing !== null) {
+ const cap = resolvePluginsCapability(toolId);
+ if (cap?.hooksDestination !== "project" || cap.projectHooksRelativePath === null) return false;
+ const { existing } = await assertProjectHooksUnchanged(
+ fs,
+ pluginName,
+ provenance,
+ toolId,
+ projectRoot
+ );
+ const hooksPath = join(projectRoot, cap.projectHooksRelativePath);
+ const hasEntries = existing !== null && contributedEntries(existing, pluginName).length > 0;
+ if (hasEntries && existing !== null) {
+ await assertProjectPathWithinRoot(fs, projectRoot, hooksPath);
const unmerged = unmergeCursorProjectHooksJson(existing, pluginName);
- // A file this route wrote in the first place — leaving it as an empty shell once
- // its last plugin is gone is the same residue clean exists to stop leaving.
if (isHooksFileEmpty(unmerged)) await fs.deleteFile(hooksPath);
else await fs.writeFile(hooksPath, unmerged);
}
- const scriptDir = join(projectRoot, cursorProjectHooksScriptDir(pluginName));
- const hadScriptDir = await fs.fileExists(scriptDir);
- if (hadScriptDir) {
- await fs.deleteDirectory(scriptDir);
- await fs.deleteEmptyDirectories(dirname(scriptDir));
+ let removedScript = false;
+ for (const relativePath of provenance?.scripts.keys() ?? []) {
+ const path = join(projectRoot, relativePath);
+ if (!(await fs.fileExists(path))) continue;
+ await assertProjectPathWithinRoot(fs, projectRoot, path);
+ await fs.deleteFile(path);
+ await fs.deleteEmptyDirectories(dirname(path));
+ removedScript = true;
+ }
+ return hasEntries || removedScript;
+}
+
+export async function removeProjectHooks(
+ fs: FileReader & FileWriter,
+ plugin: InstalledPlugin,
+ toolId: AiToolId,
+ projectRoot: string
+): Promise {
+ return removeRecordedProjectHooks(fs, plugin.name, plugin.projectHooks, toolId, projectRoot);
+}
+
+function contributedEntries(
+ content: string,
+ pluginName: string
+): readonly { event: string; entry: HookEntry }[] {
+ const parsed = JSON.parse(content) as { hooks?: Record };
+ const marker = cursorProjectHooksScriptDir(pluginName);
+ return Object.entries(parsed.hooks ?? {}).flatMap(([event, entries]) =>
+ entries
+ .filter((entry) => typeof entry.command === "string" && entry.command.includes(marker))
+ .map((entry) => ({ event, entry }))
+ );
+}
+
+/** Hash of one merged hook entry, independent of other plugins' entries. */
+export function digestEntry(entry: HookEntry): string {
+ return createHash("md5").update(JSON.stringify(entry), "utf-8").digest("hex");
+}
+
+export function recordedProjectHookEntries(
+ content: string,
+ pluginName: string
+): ProjectHooksProvenance["entries"] {
+ return contributedEntries(content, pluginName).map(({ event, entry }) => ({
+ event,
+ command: entry.command,
+ digest: digestEntry(entry),
+ }));
+}
+
+function assertScriptPath(pluginName: string, relativePath: string): void {
+ const prefix = cursorProjectHooksScriptDir(pluginName);
+ if (
+ !relativePath.startsWith(prefix) ||
+ posix.isAbsolute(relativePath) ||
+ relativePath.includes("\\") ||
+ posix.normalize(relativePath) !== relativePath
+ ) {
+ throw new Error(
+ `Cursor hook script path '${relativePath}' has unproven provenance; detach refused.`
+ );
}
- return existing !== null || hadScriptDir;
}
async function readExistingJson(fs: FileReader, path: string): Promise {
diff --git a/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts
index 3937f01f5..8a75f4d26 100644
--- a/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts
+++ b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts
@@ -1,4 +1,5 @@
-import { join } from "node:path";
+import { dirname, join, posix } from "node:path";
+import type { InstallationFile } from "../../../../kernel/file.js";
import type { FileReader } from "../../../../kernel/ports/file-reader.js";
import type { Logger } from "../../../../kernel/ports/logger.js";
import type { AiToolId } from "../../../../kernel/tool.js";
@@ -9,8 +10,8 @@ import { isStrictlyWithinUserScope } from "../../domain/plugins/user-scope-conta
/**
* The files of a user-scope plugin that are actually safe to delete: safe only once the real,
* `realpath`-resolved location still sits strictly inside the tool's own declared user-scope
- * directory. A `..` segment a corrupted manifest entry carries, or a plugin directory that became a
- * symlink after install, both fail this and are left in place and named.
+ * directory and its current content still matches the recorded install digest. A `..` segment,
+ * symlink escape, unreadable file, or user edit refuses a destructive operation before claim detach.
*/
export async function userScopeFilesSafeToDelete(
fs: FileReader,
@@ -30,6 +31,17 @@ export async function userScopeFilesSafeToDelete(
resolvedCandidate !== null &&
isStrictlyWithinUserScope(resolvedCandidate, resolvedBoundary)
) {
+ if (!/^[0-9a-f]{32}$/.test(hash)) {
+ throw new Error(
+ `${toolId}: '${plugin.name}' file '${relativePath}' has an unproven install digest; removal refused.`
+ );
+ }
+ const current = await fs.readFileHash(join(boundary, relativePath));
+ if (current.value !== hash) {
+ throw new Error(
+ `${toolId}: '${plugin.name}' file '${relativePath}' was edited after install; removal refused.`
+ );
+ }
allowed.set(relativePath, hash);
continue;
}
@@ -40,6 +52,60 @@ export async function userScopeFilesSafeToDelete(
return allowed;
}
+/** Refuse a machine update if a new path, existing parent, or plugin directory escapes its declared base. */
+export async function assertUserScopeWriteBoundary(
+ fs: FileReader,
+ toolId: AiToolId,
+ pluginName: string,
+ files: readonly InstallationFile[],
+ homedir: string
+): Promise {
+ const boundary = resolvePluginsCapability(toolId)?.userPluginsBaseDir(homedir);
+ if (boundary === null || boundary === undefined)
+ throw new Error(`${toolId}: no user plugins directory is declared.`);
+ const resolvedBoundary = await tryRealpath(fs, boundary);
+ const pluginDir = join(boundary, pluginName);
+ const resolvedPluginDir = await tryRealpath(fs, pluginDir);
+ if (
+ resolvedBoundary === null ||
+ resolvedPluginDir === null ||
+ !isStrictlyWithinUserScope(resolvedPluginDir, resolvedBoundary)
+ ) {
+ throw new Error(
+ `${toolId}: '${pluginName}' directory is not safely inside ${boundary}; update refused.`
+ );
+ }
+ for (const file of files) {
+ const rel = file.relativePath;
+ if (
+ posix.isAbsolute(rel) ||
+ rel.includes("\\") ||
+ posix.normalize(rel) !== rel ||
+ !rel.startsWith(`${pluginName}/`)
+ ) {
+ throw new Error(
+ `${toolId}: '${pluginName}' update path '${rel}' escapes its plugin directory.`
+ );
+ }
+ let parent = dirname(join(boundary, rel));
+ while (true) {
+ const resolved = await tryRealpath(fs, parent);
+ if (resolved !== null) {
+ if (!isStrictlyWithinUserScope(resolved, resolvedBoundary)) {
+ throw new Error(
+ `${toolId}: '${pluginName}' update parent '${parent}' escapes ${boundary}.`
+ );
+ }
+ break;
+ }
+ if (parent === pluginDir) {
+ throw new Error(`${toolId}: '${pluginName}' directory disappeared during update.`);
+ }
+ parent = dirname(parent);
+ }
+ }
+}
+
async function tryRealpath(fs: FileReader, path: string): Promise {
try {
return await fs.realpath(path);
diff --git a/cli/src/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.ts
deleted file mode 100644
index 695221f92..000000000
--- a/cli/src/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { join } from "node:path";
-import { type MergeFileEntry, removeEntriesFromJson } from "../../../../kernel/merge.js";
-import type { FileReader } from "../../../../kernel/ports/file-reader.js";
-import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
-import type { Logger } from "../../../../kernel/ports/logger.js";
-import type { ToolId } from "../../../../kernel/tool.js";
-import type { McpExclusion } from "../../../tools/domain/mcp-exclusion.js";
-import type { Manifest } from "../../domain/manifest.js";
-
-export interface UninstallMcpExclusionOptions {
- toolId: ToolId;
- manifest: Manifest;
- projectRoot: string;
- mcpFilter: string[];
-}
-
-export interface UninstallMcpExclusionResult {
- toolId: ToolId;
- fileCount: number;
- deletedFiles: string[];
-}
-
-export class UninstallMcpExclusionUseCase {
- constructor(
- private readonly fs: FileReader & FileWriter,
- private readonly logger: Logger
- ) {}
-
- async execute(options: UninstallMcpExclusionOptions): Promise {
- const { toolId, manifest, projectRoot, mcpFilter } = options;
- this.logger.info(`Removing MCP entries from ${toolId}...`);
- const mergeFiles = manifest.getMergeFiles(toolId);
- const removedKeys: string[] = [];
- const exclusions: McpExclusion[] = [];
- for (const mergeFile of mergeFiles) {
- const r = await this.processOneMergeFile(mergeFile, projectRoot, mcpFilter);
- removedKeys.push(...r.keys);
- exclusions.push(...r.exclusions);
- }
- this.rebuildMergeEntries(toolId, manifest, mcpFilter);
- manifest.addExcludedMcp(toolId, exclusions);
- return { toolId, fileCount: removedKeys.length, deletedFiles: removedKeys };
- }
-
- private async processOneMergeFile(
- mergeFile: MergeFileEntry,
- projectRoot: string,
- mcpFilter: string[]
- ): Promise<{ keys: string[]; exclusions: McpExclusion[] }> {
- if (mergeFile.sectionKey === null) return { keys: [], exclusions: [] };
- const matching = mcpFilter.filter((k) => mergeFile.entries[k] !== undefined);
- if (matching.length === 0) return { keys: [], exclusions: [] };
- await this.removeKeysFromJsonFile(
- join(projectRoot, mergeFile.relativePath),
- mergeFile.sectionKey,
- matching
- );
- const exclusions = matching.map((k) => ({ configPath: mergeFile.relativePath, entryKey: k }));
- return { keys: matching, exclusions };
- }
-
- private async removeKeysFromJsonFile(
- fullPath: string,
- sectionKey: string | null,
- keysToRemove: string[]
- ): Promise {
- const content = await this.fs.readFile(fullPath);
- await this.fs.writeFile(fullPath, removeEntriesFromJson(content, sectionKey, keysToRemove));
- }
-
- private rebuildMergeEntries(toolId: ToolId, manifest: Manifest, removedKeys: string[]): void {
- const mergeFiles = manifest.getMergeFiles(toolId);
- const removedSet = new Set(removedKeys);
- const updated = mergeFiles.map((mf) => {
- const entries = { ...mf.entries };
- for (const key of removedSet) delete entries[key];
- return { ...mf, entries };
- });
- manifest.updateToolMergeFiles(toolId, updated);
- }
-}
diff --git a/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts
index 49dbb10a3..e47d67504 100644
--- a/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts
+++ b/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts
@@ -1,9 +1,13 @@
import { NoManifestError, PluginNotFoundError } from "../../../../kernel/errors.js";
+import type { FileReader } from "../../../../kernel/ports/file-reader.js";
import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
import type { AiToolId, ToolId } from "../../../../kernel/tool.js";
import { AI_TOOL_IDS } from "../../../../kernel/tool.js";
import type { Manifest } from "../../domain/manifest.js";
import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
+import { detachNativePluginRefs } from "../ownership/native-plugin-ownership.js";
+import { ProjectPluginCleanup } from "../ownership/project-plugin-cleanup.js";
+import { detachUserPlugin } from "../ownership/user-plugin-ownership.js";
import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js";
export interface UninstallPluginOptions {
@@ -20,8 +24,9 @@ export interface UninstallPluginResult {
export class UninstallPluginUseCase {
constructor(
- private readonly fs: FileWriter,
- private readonly manifestRepo: ManifestRepository
+ private readonly fs: FileWriter & FileReader,
+ private readonly manifestRepo: ManifestRepository,
+ private readonly userManifestRepo?: ManifestRepository
) {}
async execute(options: UninstallPluginOptions): Promise {
@@ -29,9 +34,27 @@ export class UninstallPluginUseCase {
const manifest = await this.manifestRepo.load();
if (manifest === null) throw new NoManifestError();
const scope = this.resolveToolScope(toolIds, manifest);
+ const userTools = scope.filter((toolId) =>
+ manifest.getPlugins(toolId).some((p) => p.name === pluginName && p.scope === "user")
+ );
+ const nativeRefs = new Map();
+ const cleanup = new ProjectPluginCleanup(this.fs, this.userManifestRepo);
+ for (const toolId of scope) {
+ const plugin = manifest.getPlugins(toolId).find((candidate) => candidate.name === pluginName);
+ if (plugin !== undefined)
+ await cleanup.assertLocalIntegrationRemovable(toolId, plugin, projectRoot);
+ const hostName = manifest
+ .getNativeRegistrations(toolId)
+ ?.marketplaces.find((m) => m.alias === plugin?.marketplace)?.hostName;
+ if (hostName !== undefined) nativeRefs.set(toolId, [`${pluginName}@${hostName}`]);
+ }
const results = await this.removeFromTools(pluginName, scope, projectRoot, manifest);
if (results.length === 0) throw new PluginNotFoundError(pluginName);
await this.manifestRepo.save(manifest);
+ await detachNativePluginRefs(this.userManifestRepo, this.fs, projectRoot, nativeRefs);
+ for (const toolId of userTools) {
+ await detachUserPlugin(this.userManifestRepo, this.fs, toolId, pluginName, projectRoot);
+ }
return results;
}
@@ -47,9 +70,22 @@ export class UninstallPluginUseCase {
manifest: Manifest
): Promise {
const results: UninstallPluginResult[] = [];
+ const cleanup = new ProjectPluginCleanup(this.fs, this.userManifestRepo);
for (const toolId of toolIds) {
const plugin = manifest.getPlugins(toolId).find((p) => p.name === pluginName);
if (plugin === undefined) continue;
+ await cleanup.removeLocalIntegration(toolId, plugin, projectRoot);
+ const registrations = manifest.getNativeRegistrations(toolId);
+ const hostName = registrations?.marketplaces.find(
+ (m) => m.alias === plugin.marketplace
+ )?.hostName;
+ if (registrations !== undefined && hostName !== undefined)
+ manifest.setNativeRegistrations(toolId, {
+ ...registrations,
+ pluginRefs: registrations.pluginRefs.filter(
+ (ref) => ref !== `${plugin.name}@${hostName}`
+ ),
+ });
const deletedFiles = await deletePluginFilesForTool(
plugin.files,
plugin.scope,
diff --git a/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts
index 006b1bdb2..9b2e7e8ac 100644
--- a/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts
+++ b/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts
@@ -7,10 +7,13 @@ import {
import type { FileReader } from "../../../../kernel/ports/file-reader.js";
import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
import type { Logger } from "../../../../kernel/ports/logger.js";
-import type { ToolId } from "../../../../kernel/tool.js";
+import type { AiToolId, ToolId } from "../../../../kernel/tool.js";
import { isAiToolId } from "../../../../kernel/tool.js";
import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js";
import type { Manifest } from "../../domain/manifest.js";
+import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
+import { detachNativePluginRefs } from "../ownership/native-plugin-ownership.js";
+import { detachUserPlugin } from "../ownership/user-plugin-ownership.js";
import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js";
export interface UninstallToolsOptions {
@@ -28,7 +31,8 @@ export interface UninstallToolsResult {
export class UninstallToolsUseCase {
constructor(
private readonly fs: FileReader & FileWriter,
- private readonly logger: Logger
+ private readonly logger: Logger,
+ private readonly userManifestRepo?: ManifestRepository
) {}
async execute(options: UninstallToolsOptions): Promise {
@@ -40,6 +44,17 @@ export class UninstallToolsUseCase {
return results;
}
+ async detachClaimsAfterSave(
+ projectRoot: string,
+ userPlugins: readonly { toolId: AiToolId; name: string }[],
+ nativeRefs: ReadonlyMap
+ ): Promise {
+ await detachNativePluginRefs(this.userManifestRepo, this.fs, projectRoot, nativeRefs);
+ for (const { toolId, name } of userPlugins) {
+ await detachUserPlugin(this.userManifestRepo, this.fs, toolId, name, projectRoot);
+ }
+ }
+
private async removeOneTool(
toolId: ToolId,
allToolIds: ToolId[],
diff --git a/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts
index 0354b0b2f..56def6420 100644
--- a/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts
+++ b/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts
@@ -7,17 +7,15 @@ import type { FileReader } from "../../../../kernel/ports/file-reader.js";
import type { FileWriter } from "../../../../kernel/ports/file-writer.js";
import type { Logger } from "../../../../kernel/ports/logger.js";
import type { ToolId } from "../../../../kernel/tool.js";
-import { VALID_TOOL_IDS } from "../../../../kernel/tool.js";
+import { isAiToolId, VALID_TOOL_IDS } from "../../../../kernel/tool.js";
import type { Manifest } from "../../domain/manifest.js";
import type { ManifestRepository } from "../../domain/ports/manifest-repository.js";
-import { UninstallMcpExclusionUseCase } from "./uninstall-mcp-exclusion-use-case.js";
import { UninstallPluginUseCase } from "./uninstall-plugin-use-case.js";
import { UninstallToolsUseCase } from "./uninstall-tools-use-case.js";
interface UninstallOptions {
toolIds: ToolId[];
projectRoot: string;
- mcpFilter: string[];
pluginName?: string;
}
@@ -30,20 +28,19 @@ interface UninstallToolResult {
export class UninstallUseCase {
private readonly pluginUninstall: UninstallPluginUseCase;
private readonly toolsUninstall: UninstallToolsUseCase;
- private readonly mcpExclusion: UninstallMcpExclusionUseCase;
constructor(
- fs: FileReader & FileWriter,
+ private readonly fs: FileReader & FileWriter,
private readonly manifestRepo: ManifestRepository,
- logger: Logger
+ logger: Logger,
+ userManifestRepo?: ManifestRepository
) {
- this.pluginUninstall = new UninstallPluginUseCase(fs, manifestRepo);
- this.toolsUninstall = new UninstallToolsUseCase(fs, logger);
- this.mcpExclusion = new UninstallMcpExclusionUseCase(fs, logger);
+ this.pluginUninstall = new UninstallPluginUseCase(fs, manifestRepo, userManifestRepo);
+ this.toolsUninstall = new UninstallToolsUseCase(fs, logger, userManifestRepo);
}
async execute(options: UninstallOptions): Promise {
- const { toolIds, projectRoot, mcpFilter, pluginName } = options;
+ const { toolIds, projectRoot, pluginName } = options;
if (pluginName !== undefined) {
return this.pluginUninstall.execute({ pluginName, toolIds, projectRoot });
@@ -56,13 +53,24 @@ export class UninstallUseCase {
}
const manifest = await this.loadAndValidate(toolIds);
+ const userPlugins = toolIds.flatMap((toolId) =>
+ isAiToolId(toolId)
+ ? manifest
+ .getPlugins(toolId)
+ .filter((plugin) => plugin.scope === "user")
+ .map((plugin) => ({ toolId, name: plugin.name }))
+ : []
+ );
+ const nativeRefs = new Map();
+ for (const toolId of toolIds) {
+ const refs = manifest.getNativeRegistrations(toolId)?.pluginRefs;
+ if (refs !== undefined && refs.length > 0) nativeRefs.set(toolId, refs);
+ }
- const results =
- mcpFilter.length > 0
- ? await this.runMcpExclusions(toolIds, manifest, projectRoot, mcpFilter)
- : await this.toolsUninstall.execute({ toolIds, manifest, projectRoot });
+ const results = await this.toolsUninstall.execute({ toolIds, manifest, projectRoot });
await this.manifestRepo.save(manifest);
+ await this.toolsUninstall.detachClaimsAfterSave(projectRoot, userPlugins, nativeRefs);
return results;
}
@@ -74,17 +82,4 @@ export class UninstallUseCase {
}
return manifest;
}
-
- private async runMcpExclusions(
- toolIds: ToolId[],
- manifest: Manifest,
- projectRoot: string,
- mcpFilter: string[]
- ): Promise {
- const results: UninstallToolResult[] = [];
- for (const toolId of toolIds) {
- results.push(await this.mcpExclusion.execute({ toolId, manifest, projectRoot, mcpFilter }));
- }
- return results;
- }
}
diff --git a/cli/src/contexts/framework/domain/manifest.ts b/cli/src/contexts/framework/domain/manifest.ts
index 280487cd1..dc5cd67c4 100644
--- a/cli/src/contexts/framework/domain/manifest.ts
+++ b/cli/src/contexts/framework/domain/manifest.ts
@@ -3,8 +3,6 @@ import type { FileHash, InstallationFile } from "../../../kernel/file.js";
import type { MergeFileEntry } from "../../../kernel/merge.js";
import { AIDD_DIR, MANIFEST_FILENAME } from "../../../kernel/paths.js";
import type { ToolId } from "../../../kernel/tool.js";
-import type { McpExclusion } from "../../tools/domain/mcp-exclusion.js";
-import { addExclusions, removeExclusions } from "./manifest/mcp-exclusions.js";
import type { NativeRegistrations } from "./manifest/native-registrations.js";
import {
addPluginToEntry,
@@ -59,8 +57,7 @@ export class Manifest {
toolId: ToolId,
version: string,
files: InstallationFile[],
- mergeFiles: MergeFileEntry[] = [],
- excludedMcp: McpExclusion[] = []
+ mergeFiles: MergeFileEntry[] = []
): void {
const existing = this._tools.get(toolId);
this._tools.set(
@@ -70,7 +67,6 @@ export class Manifest {
version,
files,
mergeFiles,
- excludedMcp,
existingPlugins: existing?.plugins ?? [],
})
);
@@ -109,34 +105,6 @@ export class Manifest {
return tracked;
}
- getExcludedMcp(toolId: ToolId): readonly McpExclusion[] {
- return this._tools.get(toolId)?.excludedMcp ?? [];
- }
-
- addExcludedMcp(toolId: ToolId, exclusions: McpExclusion[]): void {
- const entry = this._tools.get(toolId);
- if (!entry) throw new ToolNotInManifestError(toolId);
- this._tools.set(toolId, {
- ...entry,
- excludedMcp: addExclusions(entry.excludedMcp, exclusions),
- });
- }
-
- removeExcludedMcp(toolId: ToolId, exclusions: McpExclusion[]): void {
- const entry = this._tools.get(toolId);
- if (!entry) throw new ToolNotInManifestError(toolId);
- this._tools.set(toolId, {
- ...entry,
- excludedMcp: removeExclusions(entry.excludedMcp, exclusions),
- });
- }
-
- clearExcludedMcp(toolId: ToolId): void {
- const entry = this._tools.get(toolId);
- if (!entry) throw new ToolNotInManifestError(toolId);
- this._tools.set(toolId, { ...entry, excludedMcp: [] });
- }
-
updateTrackedFileHash(toolId: ToolId, relativePath: string, hash: FileHash): void {
const entry = this._tools.get(toolId);
if (!entry) return;
@@ -146,18 +114,10 @@ export class Manifest {
});
}
- updateToolMergeFiles(
- toolId: ToolId,
- mergeFiles: MergeFileEntry[],
- excludedMcp?: McpExclusion[]
- ): void {
+ updateToolMergeFiles(toolId: ToolId, mergeFiles: MergeFileEntry[]): void {
const entry = this._tools.get(toolId);
if (!entry) throw new ToolNotInManifestError(toolId);
- this._tools.set(toolId, {
- ...entry,
- mergeFiles,
- ...(excludedMcp !== undefined && { excludedMcp }),
- });
+ this._tools.set(toolId, { ...entry, mergeFiles });
}
removeTool(toolId: ToolId): void {
diff --git a/cli/src/contexts/framework/domain/manifest/mcp-exclusions.ts b/cli/src/contexts/framework/domain/manifest/mcp-exclusions.ts
deleted file mode 100644
index 877d2d156..000000000
--- a/cli/src/contexts/framework/domain/manifest/mcp-exclusions.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { type McpExclusion, mcpExclusionEquals } from "../../../tools/domain/mcp-exclusion.js";
-
-export interface McpExclusionData {
- configPath: string;
- entryKey: string;
-}
-
-export function addExclusions(
- existing: readonly McpExclusion[],
- toAdd: readonly McpExclusion[]
-): McpExclusion[] {
- const result = [...existing];
- for (const excl of toAdd) {
- if (!result.some((e) => mcpExclusionEquals(e, excl))) {
- result.push(excl);
- }
- }
- return result;
-}
-
-export function removeExclusions(
- existing: readonly McpExclusion[],
- toRemove: readonly McpExclusion[]
-): McpExclusion[] {
- return existing.filter((e) => !toRemove.some((r) => mcpExclusionEquals(e, r)));
-}
-
-export function toMcpExclusionData(exclusions: readonly McpExclusion[]): McpExclusionData[] {
- return exclusions.map((e) => ({ configPath: e.configPath, entryKey: e.entryKey }));
-}
-
-export function parseMcpExclusionData(data: readonly McpExclusionData[]): McpExclusion[] {
- return data.map((e) => ({ configPath: e.configPath, entryKey: e.entryKey }));
-}
diff --git a/cli/src/contexts/framework/domain/manifest/native-registrations.ts b/cli/src/contexts/framework/domain/manifest/native-registrations.ts
index 0a95699fe..fa6f4e754 100644
--- a/cli/src/contexts/framework/domain/manifest/native-registrations.ts
+++ b/cli/src/contexts/framework/domain/manifest/native-registrations.ts
@@ -1,3 +1,5 @@
+import type { NativeMarketplaceSource } from "../../../tools/domain/ports/native-marketplace-source-reader.js";
+
/** One marketplace registration a tool's own CLI was asked to make — aidd's own local name for it
* (`alias`, what this project's registry is keyed by) beside what the host actually registered it
* under (`hostName`, the catalog's own declared name, which every host-facing call must use
@@ -6,18 +8,27 @@
export interface NativeMarketplaceRegistration {
readonly alias: string;
readonly hostName: string;
+ /** Missing on legacy records: never infer current host ownership from the name alone. */
+ readonly provenance?: NativeMarketplaceSource;
}
export interface NativeRegistrations {
readonly binary: string;
readonly marketplaces: readonly NativeMarketplaceRegistration[];
readonly pluginRefs: readonly string[];
+ readonly pluginClaims?: readonly NativePluginClaim[];
+}
+
+export interface NativePluginClaim {
+ readonly ref: string;
+ readonly dependents: readonly string[];
}
export interface NativeRegistrationsData {
binary: string;
marketplaces: NativeMarketplaceRegistration[];
pluginRefs: string[];
+ pluginClaims?: NativePluginClaim[];
}
export function toNativeRegistrationsData(
@@ -25,8 +36,19 @@ export function toNativeRegistrationsData(
): NativeRegistrationsData {
return {
binary: registrations.binary,
- marketplaces: registrations.marketplaces.map((m) => ({ ...m })),
+ marketplaces: registrations.marketplaces.map((m) => ({
+ ...m,
+ ...(m.provenance === undefined ? {} : { provenance: { ...m.provenance } }),
+ })),
pluginRefs: [...registrations.pluginRefs],
+ ...(registrations.pluginClaims === undefined
+ ? {}
+ : {
+ pluginClaims: registrations.pluginClaims.map((claim) => ({
+ ref: claim.ref,
+ dependents: [...claim.dependents],
+ })),
+ }),
};
}
@@ -36,7 +58,33 @@ export function parseNativeRegistrations(
if (data === undefined) return undefined;
return {
binary: data.binary,
- marketplaces: data.marketplaces.map((m) => ({ alias: m.alias, hostName: m.hostName })),
+ marketplaces: data.marketplaces.map((m) => ({
+ alias: m.alias,
+ hostName: m.hostName,
+ ...(validProvenance(m.provenance) ? { provenance: { ...m.provenance } } : {}),
+ })),
pluginRefs: [...data.pluginRefs],
+ ...(data.pluginClaims === undefined
+ ? {}
+ : {
+ pluginClaims: data.pluginClaims.map((claim) => ({
+ ref: claim.ref,
+ dependents: [...claim.dependents],
+ })),
+ }),
};
}
+
+function validProvenance(value: unknown): value is NativeMarketplaceSource {
+ if (value === null || typeof value !== "object") return false;
+ const data = value as Record;
+ if (typeof data.source !== "string" || data.source === "") return false;
+ if (data.kind === "registry") return true;
+ return (
+ data.kind === "effective-list" &&
+ typeof data.root === "string" &&
+ data.root !== "" &&
+ typeof data.sourceType === "string" &&
+ data.sourceType !== ""
+ );
+}
diff --git a/cli/src/contexts/framework/domain/manifest/tool-entry.ts b/cli/src/contexts/framework/domain/manifest/tool-entry.ts
index 5e84b596e..57adee527 100644
--- a/cli/src/contexts/framework/domain/manifest/tool-entry.ts
+++ b/cli/src/contexts/framework/domain/manifest/tool-entry.ts
@@ -2,13 +2,7 @@ import { DuplicatePluginError, PluginNotFoundError } from "../../../../kernel/er
import type { InstallationFile } from "../../../../kernel/file.js";
import type { MergeFileEntry } from "../../../../kernel/merge.js";
import type { ToolId } from "../../../../kernel/tool.js";
-import type { McpExclusion } from "../../../tools/domain/mcp-exclusion.js";
import { InstalledPlugin, type PluginEntryData } from "../plugins/installed-plugin.js";
-import {
- type McpExclusionData,
- parseMcpExclusionData,
- toMcpExclusionData,
-} from "./mcp-exclusions.js";
import {
type MergeFileEntryData,
parseMergeFileEntries,
@@ -33,7 +27,6 @@ export interface ToolEntry {
readonly version: string;
readonly files: readonly TrackedFile[];
readonly mergeFiles: readonly MergeFileEntry[];
- readonly excludedMcp: readonly McpExclusion[];
readonly plugins: readonly InstalledPlugin[];
/** What this tool's own CLI was asked to register, or `undefined` for a tool with
* no `nativeActivation` — see {@link NativeRegistrations}. */
@@ -45,7 +38,6 @@ export interface ToolEntryData {
version: string;
files: TrackedFileData[];
mergeFiles?: MergeFileEntryData[];
- excludedMcp?: McpExclusionData[];
plugins?: PluginEntryData[];
nativeRegistrations?: NativeRegistrationsData;
}
@@ -55,7 +47,6 @@ export function createToolEntry(params: {
version: string;
files: InstallationFile[];
mergeFiles: readonly MergeFileEntry[];
- excludedMcp: readonly McpExclusion[];
existingPlugins: readonly InstalledPlugin[];
}): ToolEntry {
return {
@@ -63,7 +54,6 @@ export function createToolEntry(params: {
version: params.version,
files: toTrackedFiles(params.files),
mergeFiles: params.mergeFiles,
- excludedMcp: params.excludedMcp,
plugins: params.existingPlugins,
};
}
@@ -104,7 +94,6 @@ export function serializeToolEntry(entry: ToolEntry): ToolEntryData {
version: entry.version,
files: toTrackedFileData(entry.files),
mergeFiles: toMergeFileEntryData(entry.mergeFiles),
- ...(entry.excludedMcp.length > 0 && { excludedMcp: toMcpExclusionData(entry.excludedMcp) }),
...(entry.plugins.length > 0 && { plugins: entry.plugins.map((p) => p.toJSON()) }),
...(entry.nativeRegistrations !== undefined && {
nativeRegistrations: toNativeRegistrationsData(entry.nativeRegistrations),
@@ -118,7 +107,6 @@ export function parseToolEntry(toolId: ToolId, data: ToolEntryData): ToolEntry {
version: data.version,
files: parseTrackedFiles(data.files),
mergeFiles: parseMergeFileEntries(data.mergeFiles ?? []),
- excludedMcp: parseMcpExclusionData(data.excludedMcp ?? []),
plugins: (data.plugins ?? []).map((p) => InstalledPlugin.fromJSON(p)),
nativeRegistrations: parseNativeRegistrations(data.nativeRegistrations),
};
diff --git a/cli/src/contexts/framework/domain/plugins/installed-plugin.ts b/cli/src/contexts/framework/domain/plugins/installed-plugin.ts
index 2ee323dd1..1753f048c 100644
--- a/cli/src/contexts/framework/domain/plugins/installed-plugin.ts
+++ b/cli/src/contexts/framework/domain/plugins/installed-plugin.ts
@@ -41,6 +41,17 @@ export type ComponentPathMap = BrandedMap<"ComponentPathMap">;
/** MCP server name → MD5 hash of the contributed server JSON (OpenCode merge tracking). */
export type McpDigestMap = BrandedMap<"McpDigestMap">;
+/** Exact merged Cursor hook commands and copied project scripts recorded at install. */
+export interface ProjectHooksProvenance {
+ entries: readonly { event: string; command: string; digest: string }[];
+ scripts: ReadonlyMap;
+}
+
+interface ProjectHooksEntryData {
+ entries: { event: string; command: string; digest: string }[];
+ scripts: Record;
+}
+
function asPathHashMap(m: ReadonlyMap): PathHashMap {
return m as PathHashMap;
}
@@ -64,7 +75,10 @@ export interface PluginEntryData {
scope: PluginScope;
componentPaths?: Record;
mcpEntries?: Record;
+ projectHooks?: ProjectHooksEntryData;
marketplace?: string;
+ /** Canonical project roots using machine-owned user-scope files. Only the user manifest owns this list. */
+ dependents?: string[];
}
export class InstalledPlugin {
@@ -76,7 +90,9 @@ export class InstalledPlugin {
readonly scope: PluginScope;
readonly componentPaths: ComponentPathMap;
readonly mcpEntries: McpDigestMap;
+ readonly projectHooks?: ProjectHooksProvenance;
readonly marketplace?: string;
+ readonly dependents: readonly string[];
private constructor(params: {
name: string;
@@ -87,7 +103,9 @@ export class InstalledPlugin {
scope: PluginScope;
componentPaths: ComponentPathMap;
mcpEntries: McpDigestMap;
+ projectHooks?: ProjectHooksProvenance;
marketplace?: string;
+ dependents: readonly string[];
}) {
this.name = params.name;
this.source = params.source;
@@ -97,7 +115,9 @@ export class InstalledPlugin {
this.scope = params.scope;
this.componentPaths = params.componentPaths;
this.mcpEntries = params.mcpEntries;
+ this.projectHooks = params.projectHooks;
this.marketplace = params.marketplace;
+ this.dependents = params.dependents;
}
static fromMetadata(
@@ -133,7 +153,28 @@ export class InstalledPlugin {
scope: plugin.scope,
componentPaths: plugin.componentPaths,
mcpEntries: asMcpDigestMap(mcpEntries),
+ projectHooks: plugin.projectHooks,
+ marketplace: plugin.marketplace,
+ dependents: plugin.dependents,
+ });
+ }
+
+ static withProjectHooks(
+ plugin: InstalledPlugin,
+ projectHooks: ProjectHooksProvenance | undefined
+ ): InstalledPlugin {
+ return new InstalledPlugin({
+ name: plugin.name,
+ source: plugin.source,
+ version: plugin.version,
+ strict: plugin.strict,
+ files: plugin.files,
+ scope: plugin.scope,
+ componentPaths: plugin.componentPaths,
+ mcpEntries: plugin.mcpEntries,
+ projectHooks,
marketplace: plugin.marketplace,
+ dependents: plugin.dependents,
});
}
@@ -209,7 +250,15 @@ export class InstalledPlugin {
scope: data.scope,
componentPaths: asComponentPathMap(componentPaths),
mcpEntries: asMcpDigestMap(mcpEntries),
+ projectHooks:
+ data.projectHooks === undefined
+ ? undefined
+ : {
+ entries: data.projectHooks.entries,
+ scripts: new Map(Object.entries(data.projectHooks.scripts)),
+ },
marketplace: data.marketplace,
+ dependents: data.dependents ?? [],
});
}
@@ -224,7 +273,14 @@ export class InstalledPlugin {
};
if (this.componentPaths.size > 0) data.componentPaths = mapToRecord(this.componentPaths);
if (this.mcpEntries.size > 0) data.mcpEntries = mapToRecord(this.mcpEntries);
+ if (this.projectHooks !== undefined) {
+ data.projectHooks = {
+ entries: [...this.projectHooks.entries],
+ scripts: mapToRecord(this.projectHooks.scripts),
+ };
+ }
if (this.marketplace !== undefined) data.marketplace = this.marketplace;
+ if (this.dependents.length > 0) data.dependents = [...this.dependents];
return data;
}
@@ -242,7 +298,9 @@ export class InstalledPlugin {
scope: this.scope,
componentPaths: this.componentPaths,
mcpEntries: this.mcpEntries,
+ projectHooks: this.projectHooks,
marketplace: this.marketplace,
+ dependents: this.dependents,
});
}
@@ -256,7 +314,25 @@ export class InstalledPlugin {
scope: this.scope,
componentPaths: this.componentPaths,
mcpEntries: this.mcpEntries,
+ projectHooks: this.projectHooks,
+ marketplace: this.marketplace,
+ dependents: this.dependents,
+ });
+ }
+
+ withDependents(dependents: readonly string[]): InstalledPlugin {
+ return new InstalledPlugin({
+ name: this.name,
+ source: this.source,
+ version: this.version,
+ strict: this.strict,
+ files: this.files,
+ scope: this.scope,
+ componentPaths: this.componentPaths,
+ mcpEntries: this.mcpEntries,
+ projectHooks: this.projectHooks,
marketplace: this.marketplace,
+ dependents: [...new Set(dependents)],
});
}
}
diff --git a/cli/src/contexts/framework/domain/ports/manifest-repository.ts b/cli/src/contexts/framework/domain/ports/manifest-repository.ts
index fb92cf05b..894fb850b 100644
--- a/cli/src/contexts/framework/domain/ports/manifest-repository.ts
+++ b/cli/src/contexts/framework/domain/ports/manifest-repository.ts
@@ -7,4 +7,6 @@ export interface ManifestRepository {
load(): Promise;
save(manifest: Manifest): Promise;
delete(): Promise;
+ /** Optional for project repositories; user-scope mutations hold an inter-process lock here. */
+ withExclusiveAccess?(action: () => Promise): Promise;
}
diff --git a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts
index b1240fd9e..8aed72d64 100644
--- a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts
+++ b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts
@@ -1,8 +1,10 @@
-import { rm } from "node:fs/promises";
+import { mkdir, rm, rmdir } from "node:fs/promises";
+import { dirname } from "node:path";
+import { setTimeout as pause } from "node:timers/promises";
import { userManifestPath } from "../../../kernel/paths.js";
import type { Manifest } from "../domain/manifest.js";
import type { ManifestRepository } from "../domain/ports/manifest-repository.js";
-import { readManifestFile, writeManifestFile } from "./manifest-file-io.js";
+import { readManifestFile } from "./manifest-file-io.js";
/**
* The user-scope counterpart of `ManifestRepositoryAdapter` — same schema, same version and refusal
@@ -14,7 +16,10 @@ import { readManifestFile, writeManifestFile } from "./manifest-file-io.js";
* a live bug here.
*/
export class UserManifestRepositoryAdapter implements ManifestRepository {
- constructor(private readonly userConfigDir: () => string) {}
+ constructor(
+ private readonly userConfigDir: () => string,
+ private readonly atomicWriter: (path: string, content: string) => Promise
+ ) {}
get path(): string {
return userManifestPath(this.userConfigDir());
@@ -29,10 +34,36 @@ export class UserManifestRepositoryAdapter implements ManifestRepository {
}
async save(manifest: Manifest): Promise {
- await writeManifestFile(this.path, manifest);
+ await mkdir(dirname(this.path), { recursive: true });
+ await this.atomicWriter(this.path, JSON.stringify(manifest.toJSON(), null, 2));
}
async delete(): Promise {
await rm(this.path, { force: true });
}
+
+ async withExclusiveAccess(action: () => Promise): Promise {
+ const lock = `${this.path}.lock`;
+ await mkdir(dirname(lock), { recursive: true });
+ const deadline = Date.now() + 30_000;
+ while (true) {
+ try {
+ await mkdir(lock);
+ break;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
+ if (Date.now() >= deadline) {
+ throw new Error(
+ `User manifest is busy: ${lock}. Retry after the other AIDD operation finishes.`
+ );
+ }
+ await pause(100);
+ }
+ }
+ try {
+ return await action();
+ } finally {
+ await rmdir(lock);
+ }
+ }
}
diff --git a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts b/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts
index 2a1758423..2e3e101be 100644
--- a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts
+++ b/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts
@@ -17,6 +17,11 @@ import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence
import type { TelemetrySink } from "../domain/ports/telemetry-sink.js";
import type { VersionControl } from "../domain/ports/version-control.js";
import { resolveSessionAnchor } from "../domain/session-anchor.js";
+import {
+ anchorProjectElsewhere,
+ resolveSessionProject,
+ type SessionProject,
+} from "../domain/session-project.js";
import {
attributeMoment,
buildStepIntervals,
@@ -230,6 +235,7 @@ export class DiagnoseTelemetryUseCase {
const unrecognisedPayload = await this.evidence.readUnrecognisedPayload(options.projectRoot);
const hookTrust = await this.resolveHookTrust(options.env, currentSessionId);
const toolReads = await this.gatherToolReads(journals);
+ const anchorInAnotherProject = await this.resolveAnchorProject(currentSessionId, journals);
return {
journals: journals.map(toClaimJournal),
toolReads,
@@ -237,6 +243,7 @@ export class DiagnoseTelemetryUseCase {
currentSessionId,
unrecognisedPayloadAt: unrecognisedPayload?.at,
hookTrust,
+ ...(anchorInAnotherProject === null ? {} : { anchorInAnotherProject }),
recorderDeclared: recorderDeclaration.declared,
recorderDeclarationReadable: recorderDeclaration.unreadable.length === 0,
foreignSchemaVersions: await this.runJournalReader.listForeignSchemas(),
@@ -258,6 +265,21 @@ export class DiagnoseTelemetryUseCase {
return null;
}
+ /** Asked only of an anchor that left no run file here: one journalled here is ours already. */
+ private async resolveAnchorProject(
+ currentSessionId: string | undefined,
+ journals: readonly RunJournal[]
+ ): Promise {
+ if (currentSessionId === undefined) return null;
+ if (journals.some((journal) => journal.session?.vendor_id === currentSessionId)) return null;
+ const here = journals
+ .map(resolveSessionProject)
+ .filter((project): project is SessionProject => project !== null);
+ if (here.length === 0) return null;
+ const stored = await this.telemetrySink.readRecordsForVendor(currentSessionId);
+ return anchorProjectElsewhere(here, stored);
+ }
+
// Only Codex gates a hook behind a trust grant it can decline in silence: a session
// running under any other tool has nothing to read here, and asks nothing of it.
private async resolveHookTrust(
diff --git a/cli/src/contexts/telemetry/domain/session-project.ts b/cli/src/contexts/telemetry/domain/session-project.ts
index 530399cc1..e34c33c53 100644
--- a/cli/src/contexts/telemetry/domain/session-project.ts
+++ b/cli/src/contexts/telemetry/domain/session-project.ts
@@ -9,6 +9,12 @@ export interface SessionProject {
readonly projectField: ProjectField;
}
+/** A stored record's project, and which field named it — the pair, never the value alone. */
+export interface RecordProjectClaim {
+ readonly project_id?: string;
+ readonly project_field?: string;
+}
+
/** `project_remote` wins when present: one value for every checkout of a repository, where
* `project_id` carries no such guarantee. Neither field named answers `null`, never a guess. */
export function resolveSessionProject(journal: RunJournal | null): SessionProject | null {
@@ -22,3 +28,31 @@ export function resolveSessionProject(journal: RunJournal | null): SessionProjec
}
return null;
}
+
+function projectOfRecord(record: RecordProjectClaim): SessionProject | null {
+ const { project_id: projectId, project_field: projectField } = record;
+ if (projectId === undefined || projectId === "") return null;
+ if (projectField !== "project_id" && projectField !== "project_remote") return null;
+ return { projectId, projectField };
+}
+
+function sameProject(left: SessionProject, right: SessionProject): boolean {
+ return left.projectField === right.projectField && left.projectId === right.projectId;
+}
+
+/** The project the stored records put an anchored session under, when it is not one of `here`'s.
+ * Compares only values named by the same field — `project_id` is a directory name and
+ * `project_remote` a URL, so across the two a match reads as a mismatch — and one record naming
+ * a project in `here` settles the session as this project's, whatever the others say. */
+export function anchorProjectElsewhere(
+ here: readonly SessionProject[],
+ anchorRecords: readonly RecordProjectClaim[]
+): string | null {
+ const comparable = anchorRecords
+ .map(projectOfRecord)
+ .filter((project): project is SessionProject => project !== null)
+ .filter((project) => here.some((mine) => mine.projectField === project.projectField));
+ if (comparable.length === 0) return null;
+ if (comparable.some((project) => here.some((mine) => sameProject(mine, project)))) return null;
+ return [...new Set(comparable.map((project) => project.projectId))].join(", ");
+}
diff --git a/cli/src/contexts/telemetry/domain/telemetry-claim.ts b/cli/src/contexts/telemetry/domain/telemetry-claim.ts
index 9dec40064..e10e34ef6 100644
--- a/cli/src/contexts/telemetry/domain/telemetry-claim.ts
+++ b/cli/src/contexts/telemetry/domain/telemetry-claim.ts
@@ -32,6 +32,7 @@ export type TelemetryClaimReason =
| NoRunFileReason
| "unrecognised-payload"
| "session-left-no-run-file"
+ | "anchor-in-another-project"
| "no-session-anchor"
| "turn-closed"
| "only-session-start"
@@ -88,6 +89,10 @@ export interface TelemetryEvidence {
readonly currentSessionId?: string;
readonly unrecognisedPayloadAt?: string;
readonly hookTrust?: TelemetryCodexHookTrust;
+ /** The project the stored records put the anchored session under, present only when that is
+ * decidably not this one: an anchor is inherited by any process nested inside a session,
+ * whatever directory it runs in. */
+ readonly anchorInAnotherProject?: string;
/** Whether the recorder is declared anywhere this build checks — read the same way
* `TelemetrySetup`'s own `recorderDeclaration` is, so the two cannot disagree. Never proof
* the hook will fire: a declaration can be silently dropped. */
@@ -256,13 +261,30 @@ function noAnchorClaim(journals: readonly TelemetryClaimJournal[], latest: strin
};
}
+function anotherProjectClaim(project: string, latest: string): TelemetryClaim {
+ return {
+ claim: "hook-fired",
+ verdict: "unknown",
+ reason: "anchor-in-another-project",
+ detail:
+ `this session belongs to ${project}, not to this project — its stored figures name that ` +
+ `project, and nothing here is evidence about its hook. The newest run file here is from ` +
+ latest,
+ };
+}
+
function sessionAnchoredClaim(
journals: readonly TelemetryClaimJournal[],
latest: string,
currentSessionId: string,
- hookTrust: TelemetryCodexHookTrust | undefined
+ hookTrust: TelemetryCodexHookTrust | undefined,
+ anchorInAnotherProject: string | undefined
): TelemetryClaim {
if (!firedForSession(journals, currentSessionId)) {
+ // Before the trust gate: local trust state says nothing about another project's session.
+ if (anchorInAnotherProject !== undefined) {
+ return anotherProjectClaim(anchorInAnotherProject, latest);
+ }
if (hookTrust && trustExplainsAbsence(hookTrust)) return untrustedHookClaim(hookTrust);
return {
claim: "hook-fired",
@@ -301,7 +323,8 @@ function claimHookFired(evidence: TelemetryEvidence): TelemetryClaim {
sessionJournals,
latest,
evidence.currentSessionId,
- evidence.hookTrust
+ evidence.hookTrust,
+ evidence.anchorInAnotherProject
);
}
diff --git a/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts b/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts
index 0a7ce0384..635eea2b0 100644
--- a/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts
+++ b/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts
@@ -102,12 +102,8 @@ export function telemetrySinkRecordDayKey(record: TelemetrySinkRecord): string |
// casts the rest, so a number here would parse as epoch milliseconds, land outside every
// real period and go missing from the read without being counted as undated.
if (typeof at !== "string") return undefined;
- // The parse is checked first, always: the slice below is a faster way to read a moment
- // already known to parse, never a substitute for checking it does. Slicing first lets a
- // string merely shaped like a moment ("not-a-momentZ") answer a calendar fragment.
const parsed = new Date(at);
if (Number.isNaN(parsed.getTime())) return undefined;
- if (at.length >= DAY_KEY_LENGTH && at.endsWith("Z")) return at.slice(0, DAY_KEY_LENGTH);
return parsed.toISOString().slice(0, DAY_KEY_LENGTH);
}
diff --git a/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts
index ac65d1386..9d7c55511 100644
--- a/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts
+++ b/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts
@@ -95,13 +95,13 @@ function parseBoundary(parsed: RawJournalLine): RunJournalBoundary | null {
return { type: "step_start", at, skill, ...(turnId === undefined ? {} : { turn_id: turnId }) };
}
-/** A plain checkout writes neither key; `asString` rejects `""`, so a torn or empty value
- * reads as "not stated" rather than as a worktree named nothing. */
+/** A plain checkout writes neither key, and an empty value is dropped with them, so a torn
+ * or empty value reads as "not stated" rather than as a worktree named nothing. */
function parseWorktree(
parsed: RawJournalLine
): Pick {
- const worktreeId = asString(parsed.worktree_id);
- const worktreeRepoId = asString(parsed.worktree_repo_id);
+ const worktreeId = asString(parsed.worktree_id) || undefined;
+ const worktreeRepoId = asString(parsed.worktree_repo_id) || undefined;
return {
...(worktreeId === undefined ? {} : { worktree_id: worktreeId }),
...(worktreeRepoId === undefined ? {} : { worktree_repo_id: worktreeRepoId }),
diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts
index 250c1d7fb..dbc9c403b 100644
--- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts
+++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts
@@ -33,6 +33,8 @@ export interface NativeActivation {
/** Verb this CLI uses to re-index its marketplaces, after `plugin marketplace`. Omit when
* plugins are not enabled through the CLI. */
upgradeVerb?: string;
+ /** Exact host command for updating one installed plugin ref. Copilot declares `update`; Codex does not. */
+ updateVerb?: string;
/** Verb this CLI uses to enable a plugin, after `plugin`. Omit when the tool loads plugins
* from a project file this CLI writes. */
enableVerb?: string;
diff --git a/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts b/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts
index 55dba42ca..099c477f0 100644
--- a/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts
+++ b/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts
@@ -1,3 +1,4 @@
+import { createHash } from "node:crypto";
import type { Hasher } from "../../../../kernel/ports/hasher.js";
import { stripJsonComments } from "../../../../kernel/reading/jsonc.js";
@@ -24,11 +25,16 @@ export function mergeOpencodeMcp(
mergedContent: string;
contributedEntries: ReadonlyMap;
collisions: ReadonlyArray;
+ editedPreviousEntries: ReadonlyArray;
} {
const { full, mcp } = parseExisting(existingContent);
const incoming = parseIncoming(incomingTransformed);
- const cleaned = stripPreviousEntries(mcp, previousEntriesForThisPlugin);
- return applyIncoming(full, cleaned, incoming, previousEntriesForThisPlugin, hasher);
+ const { cleaned, editedPreviousEntries } = stripPreviousEntries(
+ mcp,
+ previousEntriesForThisPlugin,
+ hasher
+ );
+ return { ...applyIncoming(full, cleaned, incoming, hasher), editedPreviousEntries };
}
/**
@@ -43,20 +49,34 @@ export function mergeOpencodeMcp(
export function buildOpencodeFlatConfig(
baseConfig: string,
existing: string | null,
- incoming: Record