diff --git a/CHANGELOG.md b/CHANGELOG.md index 453a514..8531e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Added preview-first host Adapters for Codex, Claude Code, and Kimi Code CLI, with pinned public contracts, App-owned ownership manifests, exact logical Diff, explicit confirmation, host discovery verification, drift protection, and rollback without changing File/Git knowledge authority. - Added an explicitly started, loopback-only, read-only OPC Dashboard for project status, acceptance, knowledge governance, manager queue, lineage evidence, and provider health. - Added fixed-field privacy projection, safe optional-provider degradation, synthetic demo data, and local server security gates without introducing Node, a database, or remote assets. - Added evidence-gated capability evolution for versioned repository roles, Skills, and organization policies, with exact Git provenance, paired bounded pilots, manager and independent-QA approvals, one-path unstaged promotion/rollback, explicit commit confirmation, v0.1 compatibility, and private auditable history. diff --git a/README.md b/README.md index f7e8217..4a3f79b 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,13 @@ preserve project `.opc`, File/Git knowledge, Git history, user configuration, and Mem0 data. See [OPC App](docs/opc-app.en.md) for the product, installation, privacy, and recovery contract. +The App also exposes a preview-first Adapters page for already installed Codex, +Claude Code, and Kimi Code CLI hosts. Codex reuses the existing Marketplace +lifecycle, Claude uses its public plugin CLI when the pinned version is safe, +and Kimi manages only its documented user Skill projection while non-interactive +Plugin management remains blocked. See [Host Adapters](docs/host-adapters.md) +for the capability matrix, limits, ownership rules, and independent QA steps. + ### Compatible local Dashboard The `main` branch includes an explicitly started, loopback-only, read-only Dashboard for manager visibility. It is not part of stable `v0.1.0`, does not scan for projects, and has no approval or promotion actions: @@ -193,6 +200,7 @@ Capability evolution is an evidence-gated private lifecycle for versioned roles, | [Capability evolution](docs/capability-evolution.md) | Versioned role/Skill/policy pilots, evidence gates, one-path Git handoff, observation, and rollback | | [OPC Dashboard](docs/opc-dashboard.md) | Explicitly started local read-only manager view, data semantics, security boundaries, and limitations | | [OPC App](docs/opc-app.en.md) | Independent local control plane, App-owned settings, install lifecycle, privacy, and Dashboard compatibility | +| [Host Adapters](docs/host-adapters.md) | Codex, Claude, and Kimi capability matrix, preview/apply lifecycle, limits, and recovery | | [v0.2 release readiness](docs/release-readiness-v0.2.0.md) | Public synthetic evidence, private 3–5 task pilot protocol, exact-commit gates, blockers, and non-claims | | [Roadmap](docs/roadmap.md) | Planned delivery stages | diff --git a/README.zh-CN.md b/README.zh-CN.md index 5d97bd7..657bc56 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -97,6 +97,12 @@ App 的更新、回滚与卸载不会删除项目 `.opc`、File/Git knowledge、 用户配置或 Mem0 数据。产品边界、安装、隐私与恢复契约见 [OPC App](docs/opc-app.md)。 +App 还提供默认只预览的 Adapters 页面,用来管理用户已安装的 Codex、Claude +Code 与 Kimi Code CLI 中的 OPC 集成。Codex 复用现有 Marketplace 生命周期; +Claude 仅在钉住的安全版本范围内调用公开 Plugin CLI;Kimi 只管理官方公开的 +用户 Skill 投影,非交互 Plugin 安装仍明确阻止。能力矩阵、所有权规则、限制与 +独立验收步骤见 [Host Adapters](docs/host-adapters.md)。 + ### 兼容的本地 Dashboard `main` 提供一个显式启动、仅本机访问、只读的经理 Dashboard。它不属于稳定版 `v0.1.0`,不会扫描磁盘发现项目,也没有批准或晋升操作: @@ -208,6 +214,7 @@ Hook/运行事件只进入私有 `PLUGIN_DATA` 或项目 `.opc` 回退,绝不 | [受控能力进化](docs/capability-evolution.md) | 角色/Skill/策略版本、证据门禁、单路径 Git 交接、观察与回滚 | | [OPC Dashboard](docs/opc-dashboard.md) | 显式启动的本地只读经理视图、数据语义、安全边界与已知限制 | | [OPC App](docs/opc-app.md) | 独立本地控制平面、App 自有设置、安装生命周期、隐私与 Dashboard 兼容性 | +| [Host Adapters](docs/host-adapters.md) | Codex、Claude、Kimi 能力矩阵、预览/执行生命周期、限制与恢复 | | [v0.2 发布就绪度](docs/release-readiness-v0.2.0.md) | 公开 synthetic 证据、私有 3–5 task 协议、exact-commit Gate、阻断项与非主张 | | [路线图](docs/roadmap.md) | 分阶段交付计划 | diff --git a/docs/adr/0018-host-adapters-use-public-contracts.md b/docs/adr/0018-host-adapters-use-public-contracts.md new file mode 100644 index 0000000..e754ba5 --- /dev/null +++ b/docs/adr/0018-host-adapters-use-public-contracts.md @@ -0,0 +1,56 @@ +# ADR-0018: Host Adapters use public lifecycle contracts + +- Status: Accepted +- Date: 2026-07-25 +- Depends on: ADR-0002, ADR-0005, ADR-0017 + +## Context + +OPC must project one canonical package into several local Agent hosts without +making their extension models appear equivalent. Blind file copying would +bypass official discovery, create ambiguous ownership, and make safe uninstall +impossible. + +## Decision + +The App calls a host-neutral Adapter core with stable `probe`, preview-plan, +explicit `apply`, `verify`, and `rollback` stages. Host implementations remain +separate: + +- Codex delegates to the existing Marketplace/Plugin lifecycle and + `plugin_admin.py`. +- Claude uses its documented non-interactive Marketplace Plugin CLI and + preserves plugin data during uninstall. One stable App-owned marketplace + path is registered once; update and rollback atomically change that source + and use public marketplace/plugin update commands. Marketplace removal is + not an update or rollback primitive. +- Kimi manages documented user Skill directories and passes their common scan + root once to `--skills-dir`. Plugin automation remains blocked while the + public Plugin manager is interactive-only. + +Preview plans are short-lived, one-time, and process-local. Apply re-captures +and compares the source, host/version, official discovery, capability contract, +ownership manifest, and resolved target state before the first write. + +File/Git remains authoritative. Adapter manifests and backups are operational, +rebuildable App state, not organizational memory. A host target may be changed +or removed only when the manifest proves OPC ownership and the current hash +still matches. Unknown host versions and unsupported mechanisms fail closed. + +Each host has one mutation lock spanning final state verification, host writes, +fresh-process verification, manifest publication, and operation-record +publication. An operation record is written before mutation and retains only +logical rollback identity, prior manifest, hashes, status, and a stable error +code. Completed or recoverable failed operations appear in the App as recovery +entries. Rollback is a separate Host/Origin/CSRF-protected request and the UI +requires its own confirmation dialog; it rechecks the recorded post-operation +fingerprint before changing anything. Codex and Claude cannot claim byte-level +host-cache drift because their public discovery contracts do not expose a +supported cache digest; Kimi can hash its managed public Skill directories. + +## Consequences + +The three hosts can expose different support levels without false parity. +Plans are auditable and writes require explicit confirmation. Real installed +state still needs independent host-specific QA; Developer-run disposable-host +acceptance is implementation evidence, not release approval. diff --git a/docs/architecture.md b/docs/architecture.md index b633eff..9040801 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -145,6 +145,7 @@ sequenceDiagram | Mem0 索引 | 用户私有数据目录 | 可删除、可重建,不是权威源 | | Dashboard 展示状态 | 进程内即时只读投影,不持久化 | 关闭本地进程即消失,不成为事实来源 | | OPC App 接入清单与偏好 | `OPC_APP_HOME` 或平台用户状态目录 | 独立于 runtime、checkout、项目和知识;可删除重建 | +| Host Adapter manifest、投影与备份 | `OPC_APP_HOME/adapters` | 仅记录 OPC 所有权、版本、Ref、哈希和恢复引用;不是组织知识 | | OPC App runtime releases | 平台用户数据目录 | 内容哈希版本可升级/回滚;卸载不触碰 App 状态或业务数据 | | 原始运行日志 | 默认最小化并设置保留策略 | 不进入公共仓库和知识层 | @@ -189,6 +190,7 @@ v0.1 以 `plugins/codex-opc-team/scripts/opc_memory.py` 为真实可调用契约 | 健康与运行状态 | `MemoryService.status()` / `doctor()` | | 本地只读经理视图 | `opc_dashboard.py` 的固定字段 `/api/snapshot`;loopback-only,不提供治理写操作 | | 共享本地投影服务 | `opc_snapshot_service.py`;旧 Dashboard 与 OPC App 使用同一 snapshot builder、DTO 和脱敏器 | +| Host Adapter 核心 | `opc_adapters.py`;UI 只调用 probe/plan/apply/verify/rollback 契约,不实现宿主安装逻辑 | | 独立本地控制平面 | `opc_app.py`;治理 API 只读,唯一写状态为显式项目清单与当前选择,不承担 Agent Harness | | 候选只读回放与 control/treatment 证据 | `opc_shadow.py preview` / `evaluate` / `report`;不属于 `MemoryService` 状态迁移 | | 角色/Skill/组织策略版本生命周期 | `opc_evolution.py` 的 private proposal/pilot/evaluate/transition/confirm;只产生一个 unstaged allowlisted diff,不修改全局 Codex 配置 | diff --git a/docs/host-adapters.md b/docs/host-adapters.md new file mode 100644 index 0000000..7ede0e5 --- /dev/null +++ b/docs/host-adapters.md @@ -0,0 +1,321 @@ +# OPC host adapters + +## Product boundary + +Adapters manage an OPC integration unit inside an already installed host. They +do not install the host application, manage accounts or models, run Agent +sessions, or change File/Git knowledge authority. + +```text +canonical OPC package + | + +-- Codex -> existing marketplace + plugin_admin.py + +-- Claude -> generated local marketplace plugin + `-- Kimi -> documented user Skill directory projection +``` + +Every mutation is `probe -> preview -> explicit confirmation -> re-probe -> +apply -> fresh-process verification`. The preview is in memory, expires after +120 seconds, is consumed on its first apply attempt, does not survive an App +restart, and performs no write. Before any mutation, the App compares a +constant-time digest over the source version/ref/content, host version and +official discovery result, capability contract, ownership manifest, and +resolved target state. Any drift fails with zero writes. +The ownership manifest is App-private state and contains only adapter/source +versions, source ref, hashes, logical managed targets, and backup references. +It never contains knowledge text, credentials, host configuration, or telemetry. + +## Capability matrix + +The machine-readable source is +`plugins/codex-opc-team/assets/adapters/capability-matrix.v1.json`. + +| Host | Verified contract | Actual managed unit | Important asymmetry | +|---|---|---|---| +| Codex | `>=0.144.1,<0.145.0` | Marketplace Plugin | Reuses `scripts/plugin_admin.py`; it does not copy the Codex cache or enable global roles/features. | +| Claude Code | `>=2.1.212,<3.0.0` | Marketplace Plugin | Uninstall always uses `--keep-data`. Versions below 2.1.212 are blocked because Anthropic documents a same-name cross-marketplace uninstall bug fixed in 2.1.212. | +| Kimi Code CLI | `>=0.29.1,<0.30.0` | User Skill projection | Kimi Plugins exist, but management is currently interactive `/plugins`; the App does not automate that surface. Fresh-process Skill discovery requires a configured model and can remain `verification_required`. | + +Unknown versions fail closed. A missing CLI is `unavailable`; an incompatible +version or unusable official discovery result is `blocked`. Neither state +writes an ownership manifest or host file. + +## Installation location resolution + +| Host | Resolution | +|---|---| +| Codex | The official Codex CLI resolves its own config and cache. The Adapter passes the canonical marketplace source to the existing lifecycle script. | +| Claude | The official Claude CLI resolves user-scope plugin state. The Adapter registers one stable App-owned marketplace source and retains immutable version/hash projections for recovery. | +| Kimi | `$KIMI_CODE_HOME/skills/` when set, otherwise `~/.kimi-code/skills/`. Verification passes the common `skills` root once to `--skills-dir` and compares exact canonical Skill identities. Tests always inject a disposable `KIMI_CODE_HOME`. | + +The browser API emits logical targets such as `kimi:skill/opc-manager`, not +absolute user-home paths. + +Codex and Claude drift detection is intentionally limited to the official +installed-state discovery result plus the App-owned immutable projection or +marketplace source. Their public CLIs do not expose a supported installed-cache +content digest, so the Adapter does not claim byte-level host-cache drift +detection. Kimi is different: its public integration unit is the managed Skill +directory itself, so every owned target is hash-verified. + +## Conflict and recovery rules + +- Install refuses an existing unknown target. +- Update and uninstall require an ownership manifest and an unchanged current + hash. User edits and links are preserved as conflicts. +- A Kimi update rejects a newly introduced canonical Skill when its target is + already user-owned, and removes a deleted canonical Skill only when the old + manifest and current hash still prove OPC ownership. +- Kimi directory swaps use App-owned staging and backups. A partial swap + restores already moved targets in reverse order. +- Claude update and rollback never remove its marketplace. The stable source + is swapped atomically, then refreshed with `plugin marketplace update` and + `plugin update`; every command is checked and a failed step restores the + previous source and plugin. Uninstall explicitly uses `--keep-data`. +- Codex knowledge initialization is explicitly skipped by the Adapter because + knowledge lifecycle is independent. +- Uninstall never touches `OPC_KNOWLEDGE_HOME`, project `.opc`, Git history, + manager preferences/rules/experience, optional Mem0 data, or other hosts. +- Verification failure is not PASS. The operation attempts rollback and returns + a structured failure or recovery requirement. +- Operation records are persisted before mutation. Successful and recoverable + failed operations expose a logical rollback ID in the App; no backup path is + returned. Rollback rechecks the exact post-operation target fingerprint and + refuses to remove user changes. +- Apply and rollback are serialized per host across fingerprint verification, + mutation, manifest publication, and operation-record publication. Different + hosts remain independent. +- `/api/adapters/rollback` is protected by the same Host, Origin, and CSRF + boundary as apply. The App additionally requires a dedicated visible + confirmation dialog before it sends the rollback request; rollback IDs alone + are not treated as authorization. + +## Installed-state QA + +Automated tests prove plan/apply separation, expiry/replay/restart behavior, +write-time state fingerprints, fake-home isolation, exact logical Diff, +ownership/hash conflict behavior, Claude intermediate-step recovery, Kimi +common-root discovery, and host command construction. + +On 2026-07-25 the Developer also ran a disposable-home acceptance pass. This is +implementation evidence, not independent QA: + +| Host | Disposable acceptance result | +|---|---| +| Codex 0.144.1 | Preview, install, fresh JSON discovery, update no-op, uninstall, reinstall, rollback, and unrelated sentinel preservation passed. | +| Claude 2.1.212 | The pinned official npm package (`@anthropic-ai/claude-code@2.1.212`, package SHA-256 `2162841dd793d21671eccb7fe76fe9c3da6816adf447ba3890a4871b7e5f4e69`) passed local marketplace install, real update, fresh JSON discovery, uninstall, reinstall, rollback, and config/plugin-data sentinel preservation. The user's installed 2.1.204 was not upgraded or used. | +| Kimi 0.29.1 | A disposable `KIMI_CODE_HOME` and loopback-only OpenAI-compatible stub passed install, exact seven-Skill request discovery, update no-op, uninstall, reinstall, rollback, and unrelated sentinel preservation. Raw prompts, requests, paths, and session identifiers were not retained. | + +Run release QA in disposable host homes: + +| Host | Independent acceptance | +|---|---| +| Codex | Install through the App, start a fresh `codex` process, run `codex plugin list --json`, and invoke one OPC Skill. Update, uninstall, rollback, and repeat discovery. | +| Claude | Use Claude Code 2.1.212 or newer, install/update/uninstall, verify with a fresh `claude plugin list --json`, and confirm plugin data remains after uninstall. | +| Kimi | Configure a disposable model/provider, install the Skill projection, start a fresh `kimi` process, invoke `/skill:opc-manager`, then repeat after update, uninstall, and rollback. | + +### Reproducible reviewer command recipe + +The repository does not claim a cross-host CI installed-state PASS: Codex, +Claude, and Kimi have different distribution and model/account prerequisites. +The following PowerShell recipe is the reproducible acceptance harness for an +independent reviewer. Run it only inside a disposable OS/container account, +from the repository root, with compatible `codex`, `claude`, and `kimi` +executables already on `PATH`. Kimi must have a disposable provider/model +configuration; `verification_required` is not PASS. + +The first block creates isolated homes and starts the real App in a background +job. It does not print credentials, plans, paths, prompts, or host output: + +```powershell +$repo = (Resolve-Path '.').Path +$qaRoot = Join-Path ([IO.Path]::GetTempPath()) ( + 'opc-adapter-review-' + [guid]::NewGuid().ToString('N') +) +$roots = @( + 'home', 'appdata', 'localappdata', 'codex', 'claude', 'kimi', + 'app-state', 'knowledge', 'data' +) +foreach ($name in $roots) { + New-Item -ItemType Directory -Force -Path (Join-Path $qaRoot $name) | Out-Null +} + +$env:HOME = Join-Path $qaRoot 'home' +$env:USERPROFILE = $env:HOME +$env:APPDATA = Join-Path $qaRoot 'appdata' +$env:LOCALAPPDATA = Join-Path $qaRoot 'localappdata' +$env:PSModuleAnalysisCachePath = Join-Path $env:LOCALAPPDATA ( + 'Microsoft\Windows\PowerShell\ModuleAnalysisCache' +) +$env:CODEX_HOME = Join-Path $qaRoot 'codex' +$env:CLAUDE_CONFIG_DIR = Join-Path $qaRoot 'claude' +$env:KIMI_CODE_HOME = Join-Path $qaRoot 'kimi' + +$sentinels = @( + (Join-Path $env:CODEX_HOME 'reviewer-sentinel.txt'), + (Join-Path $env:CLAUDE_CONFIG_DIR 'reviewer-sentinel.txt'), + (Join-Path $env:KIMI_CODE_HOME 'reviewer-sentinel.txt') +) +foreach ($path in $sentinels) { + [IO.File]::WriteAllText($path, 'must survive adapter lifecycle') +} +$before = @{} +foreach ($path in $sentinels) { + $before[$path] = (Get-FileHash -Algorithm SHA256 $path).Hash +} + +$appJob = Start-Job -ScriptBlock { + param($repo, $qaRoot) + $env:HOME = Join-Path $qaRoot 'home' + $env:USERPROFILE = $env:HOME + $env:APPDATA = Join-Path $qaRoot 'appdata' + $env:LOCALAPPDATA = Join-Path $qaRoot 'localappdata' + $env:PSModuleAnalysisCachePath = Join-Path $env:LOCALAPPDATA ( + 'Microsoft\Windows\PowerShell\ModuleAnalysisCache' + ) + $env:CODEX_HOME = Join-Path $qaRoot 'codex' + $env:CLAUDE_CONFIG_DIR = Join-Path $qaRoot 'claude' + $env:KIMI_CODE_HOME = Join-Path $qaRoot 'kimi' + Set-Location $repo + python plugins/codex-opc-team/scripts/opc_app.py ` + --state-root (Join-Path $qaRoot 'app-state') ` + --knowledge-root (Join-Path $qaRoot 'knowledge') ` + --data-root (Join-Path $qaRoot 'data') ` + --host 127.0.0.1 --port 8570 --no-open +} -ArgumentList $repo, $qaRoot + +$base = 'http://127.0.0.1:8570' +$ready = $false +foreach ($attempt in 1..50) { + try { + $null = Invoke-RestMethod "$base/api/adapters" + $ready = $true + break + } catch { + Start-Sleep -Milliseconds 100 + } +} +if (-not $ready) { throw 'OPC App did not become ready' } +$context = Invoke-RestMethod "$base/api/app-context" +$mutationHeaders = @{ + Origin = $base + 'X-OPC-CSRF' = $context.csrf_token +} +``` + +The second block exercises preview/apply, fresh-process verification, uninstall, +reinstall, and rollback for every host. It deliberately records only +privacy-safe state labels: + +```powershell +function Invoke-AdapterMutation([string]$hostId, [string]$operation) { + $planBody = @{ host_id = $hostId; operation = $operation } | + ConvertTo-Json -Compress + $plan = Invoke-RestMethod -Method Post ` + -Uri "$base/api/adapters/plan" -ContentType 'application/json' ` + -Headers $mutationHeaders ` + -Body $planBody + $applyBody = @{ + plan_id = $plan.plan_id + confirmation_token = $plan.confirmation_token + } | ConvertTo-Json -Compress + $result = Invoke-RestMethod -Method Post ` + -Uri "$base/api/adapters/apply" -ContentType 'application/json' ` + -Headers $mutationHeaders ` + -Body $applyBody + [pscustomobject]@{ + host = $hostId + operation = $operation + state = $result.state + verification = $result.verification.state + rollback_id = $result.rollback_id + } +} + +$safeResults = @() +try { + $inventory = Invoke-RestMethod "$base/api/adapters" + $inventory.hosts | + Select-Object host_id, host_version, state, reason + if ($inventory.hosts.Where({ $_.state -ne 'available' }).Count -ne 0) { + throw 'all hosts must be compatible, discoverable, and initially absent' + } + + foreach ($hostId in @('codex', 'claude', 'kimi')) { + $safeResults += Invoke-AdapterMutation $hostId 'install' + $safeResults += Invoke-AdapterMutation $hostId 'update' + $safeResults += Invoke-AdapterMutation $hostId 'uninstall' + $reinstall = Invoke-AdapterMutation $hostId 'install' + $safeResults += $reinstall + $rollbackBody = @{ + host_id = $hostId + rollback_id = $reinstall.rollback_id + } | ConvertTo-Json -Compress + $rollback = Invoke-RestMethod -Method Post ` + -Uri "$base/api/adapters/rollback" -ContentType 'application/json' ` + -Headers $mutationHeaders ` + -Body $rollbackBody + $safeResults += [pscustomobject]@{ + host = $hostId + operation = 'rollback' + state = $rollback.state + verification = $null + rollback_id = $null + } + $postRollback = (Invoke-RestMethod "$base/api/adapters").hosts | + Where-Object host_id -eq $hostId + $safeResults += [pscustomobject]@{ + host = $hostId + operation = 'post_rollback_probe' + state = $postRollback.state + verification = $null + rollback_id = $null + } + } + + $safeResults | Select-Object host, operation, state, verification + foreach ($path in $sentinels) { + if (-not (Test-Path -LiteralPath $path)) { throw 'sentinel was removed' } + if ((Get-FileHash -Algorithm SHA256 $path).Hash -ne $before[$path]) { + throw 'sentinel was modified' + } + } +} finally { + Stop-Job $appJob -ErrorAction SilentlyContinue + Remove-Job $appJob -Force -ErrorAction SilentlyContinue +} +git status --short +``` + +PASS requires `completed`/`verified` for mutations that install or remove +content, `no_change` with `verified` for an unchanged update, `rolled_back` for +rollback, `available` after the post-rollback probe, unchanged sentinels, and no +repository changes. Preserve only the +redacted result table, exact host versions, package hashes, and commit SHA. +Destroy the disposable OS/container after review. Do not publish the temporary +root, App state, raw CLI/model traffic, plan tokens, credentials, or session +identifiers. + +Independent release QA must repeat the relevant gates. Record only +privacy-safe versions, hashes, results, and reviewer identity; keep disposable +home paths and raw command/model traffic out of the public repository. Do not +accept implementer self-report as independent QA evidence. + +## Primary sources + +- Codex plugins and marketplaces: + +- Codex CLI plugin commands: + +- Claude plugin discovery: + +- Claude plugin reference: + +- Claude marketplaces and uninstall compatibility note: + +- Kimi Skills: + +- Kimi Plugins: + +- Kimi command reference: + diff --git a/plugins/codex-opc-team/assets/adapters/capability-matrix.v1.json b/plugins/codex-opc-team/assets/adapters/capability-matrix.v1.json new file mode 100644 index 0000000..6a1761f --- /dev/null +++ b/plugins/codex-opc-team/assets/adapters/capability-matrix.v1.json @@ -0,0 +1,74 @@ +{ + "schema_version": "opc-adapters.capability-matrix.v1", + "verified_at": "2026-07-25", + "adapter_version": "1.0.0", + "hosts": [ + { + "host_id": "codex", + "verified_range": ">=0.144.1,<0.145.0", + "verified_local_version": "0.144.1", + "managed_unit": "marketplace_plugin", + "lifecycle": { + "probe": "supported", + "install": "supported", + "update": "supported", + "uninstall": "supported", + "verify": "fresh_process_json" + }, + "components": { + "skills": "supported", + "agents": "not_projected", + "hooks": "supported_by_canonical_plugin", + "mcp": "not_managed", + "apps": "supported_by_canonical_plugin" + } + }, + { + "host_id": "claude", + "verified_range": ">=2.1.212,<3.0.0", + "verified_local_version": "2.1.212", + "managed_unit": "marketplace_plugin", + "local_status": "verified_with_pinned_official_npm_package_in_disposable_home", + "verification_source": { + "package": "@anthropic-ai/claude-code@2.1.212", + "sha256": "2162841dd793d21671eccb7fe76fe9c3da6816adf447ba3890a4871b7e5f4e69" + }, + "lifecycle": { + "probe": "supported", + "install": "supported_when_compatible", + "update": "supported_when_compatible", + "uninstall": "supported_with_keep_data_when_compatible", + "verify": "fresh_process_json" + }, + "components": { + "skills": "supported", + "agents": "not_projected", + "hooks": "host_specific_not_projected", + "mcp": "host_specific_not_projected", + "apps": "unsupported" + } + }, + { + "host_id": "kimi", + "verified_range": ">=0.29.1,<0.30.0", + "verified_local_version": "0.29.1", + "managed_unit": "user_skill_projection", + "lifecycle": { + "probe": "supported", + "install": "supported", + "update": "supported", + "uninstall": "supported", + "verify": "fresh_process_common_skills_root_exact_identity_prompt_requires_configured_model", + "plugin_apply": "blocked_no_public_noninteractive_cli" + }, + "components": { + "skills": "supported", + "plugins": "interactive_only", + "agents": "not_projected", + "hooks": "plugin_only_not_managed", + "mcp": "plugin_only_not_managed", + "apps": "unsupported" + } + } + ] +} diff --git a/plugins/codex-opc-team/assets/app/dashboard.css b/plugins/codex-opc-team/assets/app/dashboard.css index f717f40..c6adad3 100644 --- a/plugins/codex-opc-team/assets/app/dashboard.css +++ b/plugins/codex-opc-team/assets/app/dashboard.css @@ -1351,6 +1351,232 @@ dialog::backdrop { font-size: 11px; } +.adapter-surface { + max-width: 1240px; +} + +.adapter-contract { + color: var(--text-500); + font-family: var(--font-mono); + font-size: .75rem; +} + +.adapter-boundary { + align-items: center; + background: color-mix(in srgb, var(--cyan-400) 7%, white); + border: 1px solid color-mix(in srgb, var(--cyan-400) 24%, var(--line)); + border-radius: 10px; + color: var(--text-500); + display: flex; + font-size: .82rem; + gap: 10px; + margin: 4px 0 20px; + padding: 11px 13px; +} + +.adapter-boundary .icon { + color: var(--cyan-600); + flex: 0 0 auto; +} + +.adapter-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.adapter-card { + background: var(--surface-muted); + border: 1px solid var(--line); + border-radius: 12px; + display: flex; + flex-direction: column; + min-height: 325px; + padding: 18px; +} + +.adapter-card.is-blocked { + border-color: color-mix(in srgb, var(--red-600) 35%, var(--line)); +} + +.adapter-card.is-warning { + border-color: color-mix(in srgb, var(--amber-600) 48%, var(--line)); +} + +.adapter-card > header, +.adapter-card > header > div, +.adapter-card > header > div > span:last-child { + display: flex; +} + +.adapter-card > header { + align-items: flex-start; + justify-content: space-between; +} + +.adapter-card > header > div { + align-items: center; + gap: 10px; +} + +.adapter-card > header > div > span:last-child { + flex-direction: column; + gap: 2px; +} + +.adapter-card header small { + color: var(--text-500); + font-size: .72rem; +} + +.adapter-mark { + align-items: center; + background: var(--ink-950); + border-radius: 9px; + color: white; + display: inline-flex; + font-weight: 760; + height: 34px; + justify-content: center; + width: 34px; +} + +.adapter-card dl { + border-top: 1px solid var(--line); + margin: 18px 0 0; +} + +.adapter-card dl div { + border-bottom: 1px solid var(--line); + display: grid; + font-size: .78rem; + gap: 8px; + grid-template-columns: 78px 1fr; + padding: 9px 0; +} + +.adapter-card dt { + color: var(--text-500); +} + +.adapter-card dd { + margin: 0; + overflow-wrap: anywhere; +} + +.adapter-warning { + background: #fff7e6; + border-radius: 8px; + color: var(--amber-600); + font-size: .75rem; + margin: 12px 0 0; + padding: 9px 10px; +} + +.adapter-recovery { + background: color-mix(in srgb, var(--amber-600) 8%, white); + border: 1px solid color-mix(in srgb, var(--amber-600) 30%, var(--line)); + border-radius: 9px; + display: grid; + gap: 8px; + margin-top: 12px; + padding: 11px; +} + +.adapter-recovery code { + color: var(--text-500); + font-size: .72rem; + overflow-wrap: anywhere; +} + +.adapter-actions { + display: flex; + gap: 8px; + margin-top: auto; + padding-top: 16px; +} + +.adapter-actions button { + flex: 1; +} + +.adapter-dialog { + max-width: 680px; +} + +.adapter-plan-meta { + display: grid; + gap: 0 20px; + grid-template-columns: 1fr 1fr; + margin: 18px 0; +} + +.adapter-plan-meta div { + border-bottom: 1px solid var(--line); + padding: 10px 0; +} + +.adapter-plan-meta dt { + color: var(--text-500); + font-size: .72rem; +} + +.adapter-plan-meta dd { + font-size: .82rem; + margin: 4px 0 0; + overflow-wrap: anywhere; +} + +.adapter-diff { + background: #edf2f7; + border-radius: 9px; + display: grid; + gap: 1px; + padding: 8px; +} + +.adapter-diff div { + align-items: center; + background: var(--surface); + display: grid; + gap: 12px; + grid-template-columns: 72px 1fr; + padding: 9px; +} + +.adapter-diff strong { + font-size: .68rem; + letter-spacing: .06em; +} + +.diff-add { + color: var(--green-600); +} + +.diff-replace { + color: var(--amber-600); +} + +.diff-remove { + color: var(--red-600); +} + +.diff-keep { + color: var(--text-500); +} + +.adapter-confirm { + align-items: flex-start; + display: flex; + font-size: .79rem; + gap: 9px; + margin: 18px 0 4px; +} + +.adapter-confirm input { + margin-top: 2px; +} + .drawer-notice { display: flex; gap: 10px; @@ -1717,6 +1943,26 @@ dialog::backdrop { } } +@media (max-width: 980px) { + .adapter-grid { + grid-template-columns: 1fr; + } + + .adapter-card { + min-height: 0; + } +} + +@media (max-width: 560px) { + .adapter-plan-meta { + grid-template-columns: 1fr; + } + + .adapter-actions { + flex-direction: column; + } +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/plugins/codex-opc-team/assets/app/dashboard.js b/plugins/codex-opc-team/assets/app/dashboard.js index 02d5ef9..e27e1e8 100644 --- a/plugins/codex-opc-team/assets/app/dashboard.js +++ b/plugins/codex-opc-team/assets/app/dashboard.js @@ -9,6 +9,7 @@ knowledge: "知识", lineage: "证据链", health: "系统健康", + adapters: "Adapters", settings: "设置", }; @@ -17,6 +18,9 @@ context: null, csrf: "", queue: new Map(), + adapters: null, + adapterPlan: null, + adapterRollback: null, refreshTimer: null, }; @@ -505,6 +509,129 @@ setText("knowledge-published", `${finiteNumber(knowledge.published)} 条可验证知识`); } + function adapterStateLabel(value) { + return { + available: "可安装", + installed: "已安装", + drifted: "检测到漂移", + blocked: "已阻止", + unavailable: "宿主不可用", + }[String(value || "")] || "未知"; + } + + function adapterReason(value) { + return { + HOST_NOT_FOUND: "未在 PATH 中发现宿主 CLI", + HOST_VERSION_INCOMPATIBLE: "宿主版本不在已验证范围", + HOST_VERSION_INCOMPATIBLE_UNINSTALL_SAFETY: "当前版本存在官方已知卸载安全缺陷", + HOST_DISCOVERY_FAILED: "官方发现命令执行失败", + HOST_DISCOVERY_INVALID: "官方发现结果无法验证", + HOST_CONFIG_INVALID: "宿主配置损坏或无法通过官方诊断", + DEMO_OR_ADAPTER_SERVICE_UNAVAILABLE: "演示模式不探测或修改真实宿主", + }[String(value || "")] || String(value || ""); + } + + function adapterAction(host, operation, label, style = "secondary-button") { + const button = document.createElement("button"); + button.type = "button"; + button.className = style; + button.textContent = label; + button.dataset.adapterHost = host.host_id; + button.dataset.adapterOperation = operation; + button.disabled = !["available", "installed", "drifted"].includes(host.state); + return button; + } + + function renderAdapters() { + const target = byId("adapter-list"); + target.replaceChildren(); + const hosts = asArray(asObject(state.adapters).hosts); + if (!hosts.length) { + target.append(emptyState("尚无 Adapter 状态", "刷新后重新执行本地只读探测。")); + return; + } + hosts.forEach((host) => { + const card = document.createElement("article"); + card.className = `adapter-card filterable is-${tone(host.state)}`; + const header = document.createElement("header"); + const identity = document.createElement("div"); + const mark = document.createElement("span"); + mark.className = "adapter-mark"; + mark.textContent = String(host.display_name || host.host_id).slice(0, 1); + const title = document.createElement("span"); + const strong = document.createElement("strong"); + strong.textContent = host.display_name || host.host_id; + const unit = document.createElement("small"); + unit.textContent = host.integration_unit || "能力未发现"; + title.append(strong, unit); + identity.append(mark, title); + const badge = document.createElement("em"); + badge.className = `status-chip is-${tone(host.state)}`; + badge.textContent = adapterStateLabel(host.state); + header.append(identity, badge); + + const facts = document.createElement("dl"); + [ + ["宿主版本", host.host_version || "未发现"], + ["已安装", host.installed_version || "—"], + ["目标版本", host.target_version || "—"], + ["验证契约", host.verified_contract || "未钉住"], + ].forEach(([label, value]) => { + const row = document.createElement("div"); + const dt = document.createElement("dt"); + const dd = document.createElement("dd"); + dt.textContent = label; + dd.textContent = value; + row.append(dt, dd); + facts.append(row); + }); + card.append(header, facts); + + if (host.reason || host.plugin_management) { + const warning = document.createElement("p"); + warning.className = "adapter-warning"; + warning.textContent = host.reason + ? adapterReason(host.reason) + : "Kimi Plugin 后台安装不受支持;当前仅管理官方 Skill 投影。"; + card.append(warning); + } + const recovery = asObject(host.recovery); + if (recovery.rollback_id) { + const recoveryNode = document.createElement("section"); + recoveryNode.className = "adapter-recovery"; + const recoveryTitle = document.createElement("strong"); + recoveryTitle.textContent = recovery.status === "failed" + ? "上次操作需要恢复" + : "可回滚上次操作"; + const recoveryId = document.createElement("code"); + recoveryId.textContent = recovery.rollback_id; + const recoveryAction = document.createElement("button"); + recoveryAction.type = "button"; + recoveryAction.className = "secondary-button"; + recoveryAction.textContent = "回滚本次操作"; + recoveryAction.dataset.adapterRollbackHost = host.host_id; + recoveryAction.dataset.adapterRollbackId = recovery.rollback_id; + recoveryAction.dataset.adapterRollbackError = recovery.error_code || ""; + recoveryNode.append(recoveryTitle, recoveryId, recoveryAction); + card.append(recoveryNode); + } + const actions = document.createElement("div"); + actions.className = "adapter-actions"; + if (host.state === "available") { + actions.append(adapterAction(host, "install", "预览安装", "primary-button")); + } else if (host.state === "installed" || (host.state === "drifted" && host.ownership)) { + actions.append(adapterAction(host, "update", host.state === "drifted" ? "查看修复计划" : "检查更新", "primary-button")); + actions.append(adapterAction(host, "uninstall", "预览卸载")); + } else { + const unavailable = adapterAction(host, "inspect", "当前不可执行"); + unavailable.disabled = true; + actions.append(unavailable); + } + card.append(actions); + target.append(card); + }); + } + function renderLineage() { const project = selectedSnapshotProject(); const stages = [...document.querySelectorAll("#lineage-timeline li")]; @@ -578,6 +705,7 @@ renderKnowledge(); renderLineage(); renderHealth(); + renderAdapters(); renderWarnings(); applySearch(); setText("live-status", "OPC App 本地状态已刷新"); @@ -597,6 +725,7 @@ if (!response.ok) { const error = new Error(payload.error || `HTTP_${response.status}`); error.code = payload.error || `HTTP_${response.status}`; + error.rollbackId = payload.rollback_id || null; throw error; } return payload; @@ -605,12 +734,14 @@ async function refresh() { byId("refresh-button").disabled = true; try { - const [context, snapshot] = await Promise.all([ + const [context, snapshot, adapters] = await Promise.all([ fetchJSON("/api/app-context"), fetchJSON("/api/snapshot"), + fetchJSON("/api/adapters"), ]); state.context = context; state.snapshot = snapshot; + state.adapters = adapters; state.csrf = String(context.csrf_token || ""); renderAll(); } catch (error) { @@ -639,6 +770,162 @@ await refresh(); } + async function adapterRequest(path, body) { + return fetchJSON(path, { + method: "POST", + headers: { + "X-OPC-CSRF": state.csrf, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + } + + function adapterErrorMessage(code) { + return { + HOST_BLOCKED: "宿主缺失、版本不兼容或官方发现不可用;未执行任何写入。", + NOT_OPC_OWNED: "没有可证明的 OPC 所有权记录,已拒绝修改。", + USER_MODIFIED_CONFLICT: "受管内容已被用户修改;已保留现场并转为人工处理。", + UNKNOWN_TARGET_CONFLICT: "目标位置已有未知内容;已保留且未覆盖。", + CONFIRMATION_REQUIRED: "必须重新核对计划并明确确认。", + PLAN_NOT_FOUND_OR_USED: "计划已过期或已经执行,请重新生成。", + PLAN_EXPIRED: "计划已超过有效期且已作废,请重新探测并生成。", + PLAN_STATE_CHANGED: "来源、宿主、发现结果、所有权或目标状态已变化;未执行任何写入。", + VERIFY_FAILED_ROLLED_BACK: "宿主回读验证失败,已尝试恢复操作前状态。", + APPLY_FAILED: "宿主写入失败;其他 Adapter 和私人知识未被修改。", + ROLLBACK_FAILED: "自动恢复未完成;现场已保留,请使用恢复入口。", + ROLLBACK_CONFLICT: "回滚点之后的目标状态已变化;为保护用户修改,已拒绝回滚。", + ROLLBACK_POINT_USED: "此回滚点已经使用,不能重复执行。", + }[String(code || "")] || "操作未完成;请刷新后查看结构化状态。"; + } + + function exposeAdapterRecovery(hostId, rollbackId, errorCode = null) { + const hosts = asArray(asObject(state.adapters).hosts); + const host = hosts.find((item) => item.host_id === hostId); + if (!host || !rollbackId) return; + host.recovery = { + rollback_id: rollbackId, + operation: asObject(state.adapterPlan).operation || "unknown", + status: errorCode ? "failed" : "completed", + error_code: errorCode, + }; + renderAdapters(); + } + + async function openAdapterPlan(hostId, operation) { + try { + const plan = await adapterRequest("/api/adapters/plan", { + host_id: hostId, + operation, + }); + state.adapterPlan = plan; + setText("adapter-dialog-host", `${hostId} · ${operation}`); + setText("adapter-plan-version", plan.source_version); + setText("adapter-plan-ref", plan.source_ref); + setText("adapter-plan-hash", String(plan.content_hash || "").slice(0, 16)); + setText( + "adapter-plan-expiry", + `${Number(plan.expires_in_seconds || 0)} 秒(单次使用)` + ); + setText("adapter-plan-rollback", plan.rollback); + const diff = byId("adapter-plan-diff"); + diff.replaceChildren(); + asArray(plan.changes).forEach((change) => { + const row = document.createElement("div"); + const action = document.createElement("strong"); + action.className = `diff-${change.action}`; + action.textContent = String(change.action || "").toUpperCase(); + const target = document.createElement("code"); + target.textContent = change.target || ""; + row.append(action, target); + diff.append(row); + }); + setText("adapter-plan-preserves", `明确保留:${asArray(plan.preserves).join("、")}`); + byId("adapter-confirm").checked = false; + byId("apply-adapter-plan").disabled = true; + byId("adapter-plan-error").hidden = true; + byId("adapter-dialog").showModal(); + } catch (error) { + showBanner(adapterErrorMessage(error.code)); + } + } + + async function applyAdapterPlan() { + const plan = asObject(state.adapterPlan); + if (!byId("adapter-confirm").checked || !plan.plan_id) return; + const button = byId("apply-adapter-plan"); + const errorNode = byId("adapter-plan-error"); + button.disabled = true; + errorNode.hidden = true; + try { + const result = await adapterRequest("/api/adapters/apply", { + plan_id: plan.plan_id, + confirmation_token: plan.confirmation_token, + }); + byId("adapter-dialog").close(); + const hostId = plan.host_id; + state.adapterPlan = null; + await refresh(); + exposeAdapterRecovery(hostId, result.rollback_id); + showBanner( + result.state === "verification_required" + ? "宿主写入已完成,但 Kimi 新进程发现仍需在已配置模型的环境中人工验收。" + : "Adapter 已执行并通过宿主官方发现机制回读。" + ); + } catch (error) { + errorNode.textContent = adapterErrorMessage(error.code); + errorNode.hidden = false; + if (error.rollbackId) { + exposeAdapterRecovery(plan.host_id, error.rollbackId, error.code); + } + } finally { + button.disabled = !byId("adapter-confirm").checked; + } + } + + function openAdapterRollback(hostId, rollbackId, errorCode = "") { + state.adapterRollback = { + host_id: hostId, + rollback_id: rollbackId, + }; + setText("adapter-rollback-host", hostId); + setText("adapter-rollback-id", rollbackId); + setText( + "adapter-rollback-reason", + errorCode + ? `上次操作返回 ${errorCode};将恢复该操作记录绑定的 OPC-owned 前置状态。` + : "将恢复该操作记录绑定的 OPC-owned 前置状态。" + ); + byId("adapter-rollback-confirm").checked = false; + byId("confirm-adapter-rollback").disabled = true; + byId("adapter-rollback-error").hidden = true; + byId("adapter-rollback-dialog").showModal(); + } + + async function rollbackAdapterOperation() { + const rollback = asObject(state.adapterRollback); + if (!byId("adapter-rollback-confirm").checked || !rollback.rollback_id) return; + const button = byId("confirm-adapter-rollback"); + const errorNode = byId("adapter-rollback-error"); + button.disabled = true; + errorNode.hidden = true; + try { + await adapterRequest("/api/adapters/rollback", { + host_id: rollback.host_id, + rollback_id: rollback.rollback_id, + }); + byId("adapter-rollback-dialog").close(); + state.adapterRollback = null; + await refresh(); + showBanner("已通过记录的回滚点恢复操作前状态,并保留私人知识与无关宿主内容。"); + } catch (error) { + errorNode.textContent = adapterErrorMessage(error.code); + errorNode.hidden = false; + } finally { + button.disabled = !byId("adapter-rollback-confirm").checked; + } + } + function errorMessage(code) { const mapping = { ABSOLUTE_PROJECT_ROOT_REQUIRED: "请输入项目的绝对目录。", @@ -817,6 +1104,32 @@ setText("live-status", "浏览器未允许复制;请手动选择文本"); } }); + byId("adapter-list").addEventListener("click", (event) => { + const rollbackButton = event.target.closest("button[data-adapter-rollback-id]"); + if (rollbackButton) { + openAdapterRollback( + rollbackButton.dataset.adapterRollbackHost, + rollbackButton.dataset.adapterRollbackId, + rollbackButton.dataset.adapterRollbackError + ); + return; + } + const button = event.target.closest("button[data-adapter-host]"); + if (!button) return; + openAdapterPlan(button.dataset.adapterHost, button.dataset.adapterOperation); + }); + byId("close-adapter-dialog").addEventListener("click", () => byId("adapter-dialog").close()); + byId("cancel-adapter-plan").addEventListener("click", () => byId("adapter-dialog").close()); + byId("adapter-confirm").addEventListener("change", (event) => { + byId("apply-adapter-plan").disabled = !event.target.checked; + }); + byId("apply-adapter-plan").addEventListener("click", applyAdapterPlan); + byId("close-adapter-rollback").addEventListener("click", () => byId("adapter-rollback-dialog").close()); + byId("cancel-adapter-rollback").addEventListener("click", () => byId("adapter-rollback-dialog").close()); + byId("adapter-rollback-confirm").addEventListener("change", (event) => { + byId("confirm-adapter-rollback").disabled = !event.target.checked; + }); + byId("confirm-adapter-rollback").addEventListener("click", rollbackAdapterOperation); } function scheduleRefresh() { diff --git a/plugins/codex-opc-team/assets/app/index.html b/plugins/codex-opc-team/assets/app/index.html index 9047752..1a275e5 100644 --- a/plugins/codex-opc-team/assets/app/index.html +++ b/plugins/codex-opc-team/assets/app/index.html @@ -29,6 +29,7 @@ +
@@ -41,6 +42,7 @@ 知识 证据链 系统健康 + Adapters 设置
@@ -164,6 +166,20 @@

总览

+ +